TYPO3 v15 dev-main snapshot ()

This commit is contained in:
2026-08-10 22:31:33 +02:00
commit 1a6eae9988
124 changed files with 11393 additions and 0 deletions
+136
View File
@@ -0,0 +1,136 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license 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\Command;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Helper\Table;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use TYPO3\CMS\Core\Registry;
use TYPO3\CMS\Core\Site\SiteFinder;
use TYPO3\CMS\Extbase\Utility\LocalizationUtility;
use TYPO3\CMS\Redirects\Service\IntegrityService;
use TYPO3\CMS\Redirects\Utility\RedirectConflict;
#[AsCommand('redirects:checkintegrity', 'Check integrity of redirects')]
class CheckIntegrityCommand extends Command
{
private const string REGISTRY_NAMESPACE = 'tx_redirects';
public const REGISTRY_KEY_CONFLICTING_REDIRECTS = 'conflicting_redirects';
public const REGISTRY_KEY_LAST_TIMESTAMP_CHECK_INTEGRITY = 'redirects_check_integrity_last_check';
private const string LANGUAGE_FILE_PATH = 'LLL:EXT:redirects/Resources/Private/Language/locallang_db.xlf';
public function __construct(
private readonly Registry $registry,
private readonly IntegrityService $integrityService,
private readonly SiteFinder $siteFinder,
) {
parent::__construct();
}
protected function configure(): void
{
$this->addArgument(
'site',
InputArgument::OPTIONAL,
'If set, then only pages of a specific site are checked',
'',
function (): array {
return array_keys($this->siteFinder->getAllSites());
}
);
}
/**
* Executes the command for checking for conflicting redirects
*/
protected function execute(InputInterface $input, OutputInterface $output): int
{
$this->registry->remove(self::REGISTRY_NAMESPACE, self::REGISTRY_KEY_CONFLICTING_REDIRECTS);
$this->registry->remove(self::REGISTRY_NAMESPACE, self::REGISTRY_KEY_LAST_TIMESTAMP_CHECK_INTEGRITY);
$conflictingRedirects = [];
$list = [];
$site = $input->getArgument('site') ?: null;
$table = new Table($output);
$table->setHeaders(
[
LocalizationUtility::translate(
self::LANGUAGE_FILE_PATH . ':sys_redirect.uid'
),
LocalizationUtility::translate(
self::LANGUAGE_FILE_PATH . ':sys_redirect.source_host'
),
LocalizationUtility::translate(
self::LANGUAGE_FILE_PATH . ':sys_redirect.source_path'
),
LocalizationUtility::translate(
self::LANGUAGE_FILE_PATH . ':sys_redirect.target'
),
LocalizationUtility::translate(
self::LANGUAGE_FILE_PATH . ':sys_redirect.integrity_status'
),
]
);
$integrityStatusLabel = ':sys_redirect.integrity_status.';
foreach ($this->integrityService->findConflictingRedirects($site) as $conflict) {
$conflictingRedirects[] = [
$conflict['redirect']['uid'],
$conflict['redirect']['source_host'],
$conflict['redirect']['source_path'],
$conflict['uri'],
LocalizationUtility::translate(
self::LANGUAGE_FILE_PATH . $integrityStatusLabel . $conflict['redirect']['integrity_status']
),
];
$list[] = $conflict;
$this->integrityService->setIntegrityStatus($conflict['redirect']);
}
foreach ($this->integrityService->checkRedirectIntegrity() as $conflict) {
if ($conflict['redirect']['integrity_status'] !== RedirectConflict::NO_CONFLICT) {
// Report only redirects with conflict status checked as conflicting redirects.
$conflictingRedirects[] = [
$conflict['redirect']['uid'],
$conflict['redirect']['source_host'],
$conflict['redirect']['source_path'],
$conflict['uri'],
LocalizationUtility::translate(
self::LANGUAGE_FILE_PATH . $integrityStatusLabel . $conflict['redirect']['integrity_status']
) ?? $conflict['redirect']['integrity_status'],
];
$list[] = $conflict;
}
// Always update redirect status
$this->integrityService->setIntegrityStatus($conflict['redirect']);
}
if ($conflictingRedirects !== []) {
$table->setRows($conflictingRedirects);
$table->render();
}
$this->registry->set(self::REGISTRY_NAMESPACE, self::REGISTRY_KEY_CONFLICTING_REDIRECTS, $list);
$this->registry->set(self::REGISTRY_NAMESPACE, self::REGISTRY_KEY_LAST_TIMESTAMP_CHECK_INTEGRITY, time());
return Command::SUCCESS;
}
}
+125
View File
@@ -0,0 +1,125 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Redirects\Command;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use TYPO3\CMS\Core\Localization\LanguageService;
use TYPO3\CMS\Core\Localization\LanguageServiceFactory;
use TYPO3\CMS\Redirects\Repository\Demand;
use TYPO3\CMS\Redirects\Repository\RedirectRepository;
#[AsCommand('redirects:cleanup', 'Cleanup old redirects periodically for given constraints like days, hit count or domains.')]
class CleanupRedirectsCommand extends Command
{
protected LanguageService $languageService;
public function __construct(
protected readonly RedirectRepository $redirectRepository,
protected readonly LanguageServiceFactory $languageServiceFactory
) {
$this->languageService = $languageServiceFactory->create('en');
parent::__construct();
}
protected function configure(): void
{
$this
->addOption(
'domain',
'd',
InputOption::VALUE_OPTIONAL | InputOption::VALUE_IS_ARRAY,
$this->languageService->sL('LLL:EXT:redirects/Resources/Private/Language/locallang.xlf:cleanupRedirectsCommand.label.domain'),
null,
function (): array {
return array_column($this->redirectRepository->findHostsOfRedirects(), 'name');
}
)
->addOption(
'statusCode',
's',
InputOption::VALUE_OPTIONAL | InputOption::VALUE_IS_ARRAY,
$this->languageService->sL('LLL:EXT:redirects/Resources/Private/Language/locallang.xlf:cleanupRedirectsCommand.label.statusCode'),
null,
function (): array {
return array_column($this->redirectRepository->findStatusCodesOfRedirects(), 'code');
}
)
->addOption(
'days',
'a',
InputOption::VALUE_OPTIONAL,
$this->languageService->sL('LLL:EXT:redirects/Resources/Private/Language/locallang.xlf:cleanupRedirectsCommand.label.days'),
null
)
->addOption(
'hitCount',
'c',
InputOption::VALUE_OPTIONAL,
$this->languageService->sL('LLL:EXT:redirects/Resources/Private/Language/locallang.xlf:cleanupRedirectsCommand.label.hitCount'),
null
)
->addOption(
'path',
'p',
InputOption::VALUE_OPTIONAL,
$this->languageService->sL('LLL:EXT:redirects/Resources/Private/Language/locallang.xlf:cleanupRedirectsCommand.label.path'),
null
)
->addOption(
'creationType',
't',
InputOption::VALUE_OPTIONAL,
$this->languageService->sL('LLL:EXT:redirects/Resources/Private/Language/locallang.xlf:cleanupRedirectsCommand.label.creationType'),
null,
function (): array {
return array_keys($this->redirectRepository->findCreationTypes());
}
)
->addOption(
'integrityStatus',
'i',
InputOption::VALUE_OPTIONAL,
$this->languageService->sL('LLL:EXT:redirects/Resources/Private/Language/locallang.xlf:cleanupRedirectsCommand.label.integrityStatus'),
null,
function (): array {
return array_keys($this->redirectRepository->findIntegrityStatusCodes());
}
)
->addOption(
'redirectType',
null,
InputOption::VALUE_OPTIONAL,
$this->languageService->sL('LLL:EXT:redirects/Resources/Private/Language/locallang.xlf:cleanupRedirectsCommand.label.redirectType'),
Demand::DEFAULT_REDIRECT_TYPE,
function (): array {
return array_keys($this->redirectRepository->findRedirectTypes());
}
)
;
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$this->redirectRepository->removeByDemand(Demand::fromCommandInput($input));
return Command::SUCCESS;
}
}
@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Redirects\Configuration;
/**
* @internal Only to be used within EXT:redirects.
*/
final class CheckIntegrityConfiguration
{
public bool $showInfoInReports = true;
public int $seconds = 86400;
public function __construct(array $extensionConfiguration)
{
$this->showInfoInReports = (bool)$extensionConfiguration['showCheckIntegrityInfoInReports'];
$this->seconds = (int)$extensionConfiguration['showCheckIntegrityInfoInReportsSeconds'];
}
}
+199
View File
@@ -0,0 +1,199 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license 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\Controller;
use Psr\EventDispatcher\EventDispatcherInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Backend\Attribute\AsController;
use TYPO3\CMS\Backend\Routing\UriBuilder;
use TYPO3\CMS\Backend\Template\Components\ComponentFactory;
use TYPO3\CMS\Backend\Template\Components\MultiRecordSelection\Action;
use TYPO3\CMS\Backend\Template\ModuleTemplate;
use TYPO3\CMS\Backend\Template\ModuleTemplateFactory;
use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
use TYPO3\CMS\Core\Configuration\Features;
use TYPO3\CMS\Core\Imaging\IconFactory;
use TYPO3\CMS\Core\Imaging\IconSize;
use TYPO3\CMS\Core\Localization\LanguageService;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Redirects\Event\ModifyRedirectManagementControllerViewDataEvent;
use TYPO3\CMS\Redirects\Repository\Demand;
use TYPO3\CMS\Redirects\Repository\RedirectRepository;
use TYPO3\CMS\Redirects\Service\ModulePaginationService;
use TYPO3\CMS\Redirects\Utility\RedirectConflict;
/**
* Lists all redirects in the TYPO3 Backend as a module.
*
* @internal This class is a specific TYPO3 Backend controller implementation and is not part of the Public TYPO3 API.
*/
#[AsController]
class ManagementController
{
public function __construct(
protected UriBuilder $uriBuilder,
protected IconFactory $iconFactory,
protected RedirectRepository $redirectRepository,
protected ModuleTemplateFactory $moduleTemplateFactory,
private EventDispatcherInterface $eventDispatcher,
protected ComponentFactory $componentFactory,
protected ModulePaginationService $modulePaginationService,
) {}
/**
* Injects the request object for the current request, and renders the overview of all redirects
*/
public function handleRequest(ServerRequestInterface $request): ResponseInterface
{
$view = $this->moduleTemplateFactory->create($request);
$demand = Demand::fromRequest($request);
$redirectType = $demand->getRedirectType();
$view->setTitle(
$this->getLanguageService()->translate('title', 'redirects.modules.redirects')
);
$view->makeDocHeaderModuleMenu();
$this->registerDocHeaderButtons($view);
if (!$this->canListRedirects()) {
return $view->renderResponse('Management/Overview');
}
$event = $this->eventDispatcher->dispatch(
new ModifyRedirectManagementControllerViewDataEvent(
$demand,
$this->redirectRepository->findRedirectsByDemand($demand),
$this->redirectRepository->findHostsOfRedirects($redirectType),
$this->redirectRepository->findStatusCodesOfRedirects($redirectType),
$this->redirectRepository->findCreationTypes($redirectType),
GeneralUtility::makeInstance(Features::class)->isFeatureEnabled('redirects.hitCount'),
$view,
$request,
$this->redirectRepository->findIntegrityStatusCodes($redirectType),
)
);
$requestUri = $request->getAttribute('normalizedParams')->getRequestUri();
$pagination = $this->modulePaginationService->preparePagination($demand);
$languageService = $this->getLanguageService();
$view = $event->getView();
$hasEditPermissions = $this->canEditRedirects();
$view->assignMultiple([
'redirects' => $event->getRedirects(),
'hosts' => $event->getHosts(),
'statusCodes' => $event->getStatusCodes(),
'creationTypes' => $event->getCreationTypes(),
'integrityStatusCodes' => $event->getIntegrityStatusCodes(),
'defaultIntegrityStatus' => RedirectConflict::NO_CONFLICT,
'demand' => $event->getDemand(),
'showHitCounter' => $event->getShowHitCounter(),
'pagination' => $pagination,
'canEditRedirects' => $hasEditPermissions,
'canListRedirects' => true,
'returnUrl' => $this->uriBuilder->buildUriFromRoute('redirects', [
'page' => $pagination['current'],
'demand' => $demand->getParameters(),
'orderField' => $demand->getOrderField(),
'orderDirection' => $demand->getOrderDirection(),
]),
'actions' => $hasEditPermissions ? [
new Action(
'edit',
[
'idField' => 'uid',
'tableName' => 'sys_redirect',
'returnUrl' => $requestUri,
],
'actions-open',
'LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:cm.edit'
),
new Action(
'delete',
[
'idField' => 'uid',
'tableName' => 'sys_redirect',
'title' => $languageService->sL('LLL:EXT:redirects/Resources/Private/Language/locallang_module_redirect.xlf:labels.delete.title'),
'content' => $languageService->sL('LLL:EXT:redirects/Resources/Private/Language/locallang_module_redirect.xlf:labels.delete.message'),
'ok' => $languageService->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:cm.delete'),
'cancel' => $languageService->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.cancel'),
'returnUrl' => $requestUri,
],
'actions-edit-delete',
'LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:cm.delete'
),
] : [],
]);
return $view->renderResponse('Management/Overview');
}
protected function canListRedirects(): bool
{
return $this->getBackendUser()->check('tables_select', 'sys_redirect');
}
protected function canEditRedirects(): bool
{
return $this->getBackendUser()->check('tables_modify', 'sys_redirect');
}
/**
* Create document header buttons
*/
protected function registerDocHeaderButtons(ModuleTemplate $view): void
{
$languageService = $this->getLanguageService();
// Create new
if ($this->canEditRedirects()) {
$newRecordButton = $this->componentFactory->createLinkButton()
->setHref((string)$this->uriBuilder->buildUriFromRoute(
'record_edit',
[
'edit' => ['sys_redirect' => ['new']],
'module' => 'redirects',
'defVals' => [
'sys_redirect' => [
'redirect_type' => Demand::DEFAULT_REDIRECT_TYPE,
],
],
'returnUrl' => (string)$this->uriBuilder->buildUriFromRoute('redirects'),
]
))
->setTitle($languageService->sL('LLL:EXT:redirects/Resources/Private/Language/locallang_module_redirect.xlf:redirect_add_text'))
->setShowLabelText(true)
->setIcon($this->iconFactory->getIcon('actions-plus', IconSize::SMALL));
$view->getDocHeaderComponent()->getButtonBar()->addButton($newRecordButton);
}
// Shortcut
$view->getDocHeaderComponent()->setShortcutContext(
'redirects',
$languageService->translate('short_description', 'redirects.modules.redirects')
);
}
protected function getLanguageService(): LanguageService
{
return $GLOBALS['LANG'];
}
protected function getBackendUser(): BackendUserAuthentication
{
return $GLOBALS['BE_USER'];
}
}
@@ -0,0 +1,155 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license 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\Controller;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Backend\Attribute\AsController;
use TYPO3\CMS\Backend\Routing\UriBuilder;
use TYPO3\CMS\Backend\Template\Components\ButtonBar;
use TYPO3\CMS\Backend\Template\Components\ComponentFactory;
use TYPO3\CMS\Backend\Template\Components\MultiRecordSelection\Action;
use TYPO3\CMS\Backend\Template\ModuleTemplate;
use TYPO3\CMS\Backend\Template\ModuleTemplateFactory;
use TYPO3\CMS\Core\Configuration\Features;
use TYPO3\CMS\Core\Imaging\IconFactory;
use TYPO3\CMS\Core\Imaging\IconSize;
use TYPO3\CMS\Core\Localization\LanguageService;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Redirects\Repository\Demand;
use TYPO3\CMS\Redirects\Repository\RedirectRepository;
use TYPO3\CMS\Redirects\Service\ModulePaginationService;
use TYPO3\CMS\Redirects\Utility\RedirectConflict;
/**
* Lists all QR Codes in the TYPO3 Backend as a module.
*
* @internal This class is a specific TYPO3 Backend controller implementation and is not part of the Public TYPO3 API.
*/
#[AsController]
class QrCodeModuleController
{
public function __construct(
protected UriBuilder $uriBuilder,
protected IconFactory $iconFactory,
protected RedirectRepository $redirectRepository,
protected ModuleTemplateFactory $moduleTemplateFactory,
protected ComponentFactory $componentFactory,
protected ModulePaginationService $modulePaginationService,
) {}
/**
* Injects the request object for the current request, and renders the overview of all QR Codes
*/
public function handleRequest(ServerRequestInterface $request): ResponseInterface
{
$view = $this->moduleTemplateFactory->create($request);
$demand = Demand::fromRequest($request);
$redirectType = $demand->getRedirectType();
$view->setTitle(
$this->getLanguageService()->translate('title', 'redirects.modules.qrcodes')
);
$view->makeDocHeaderModuleMenu();
$this->registerDocHeaderButtons($view);
$requestUri = $request->getAttribute('normalizedParams')->getRequestUri();
$languageService = $this->getLanguageService();
$pagination = $this->modulePaginationService->preparePagination($demand);
$view->assignMultiple([
'redirects' => $this->redirectRepository->findRedirectsByDemand($demand),
'hosts' => $this->redirectRepository->findHostsOfRedirects($redirectType),
'defaultIntegrityStatus' => RedirectConflict::NO_CONFLICT,
'demand' => $demand,
'showHitCounter' => GeneralUtility::makeInstance(Features::class)->isFeatureEnabled('redirects.hitCount'),
'pagination' => $pagination,
'returnUrl' => $this->uriBuilder->buildUriFromRoute('qrcodes', [
'page' => $pagination['current'],
'demand' => $demand->getParameters(),
'orderField' => $demand->getOrderField(),
'orderDirection' => $demand->getOrderDirection(),
]),
'actions' => [
new Action(
'edit',
[
'idField' => 'uid',
'tableName' => 'sys_redirect',
'returnUrl' => $requestUri,
],
'actions-open',
'LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:cm.edit'
),
new Action(
'delete',
[
'idField' => 'uid',
'tableName' => 'sys_redirect',
'title' => $languageService->translate('delete.title', 'redirects.modules.qrcodes'),
'content' => $languageService->translate('delete.message', 'redirects.modules.qrcodes'),
'ok' => $languageService->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:cm.delete'),
'cancel' => $languageService->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.cancel'),
'returnUrl' => $requestUri,
],
'actions-edit-delete',
'LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:cm.delete'
),
],
]);
return $view->renderResponse('QrCode/Overview');
}
/**
* Create document header buttons for QR codes
*/
protected function registerDocHeaderButtons(ModuleTemplate $view): void
{
$languageService = $this->getLanguageService();
// Create new
$newRecordButton = $this->componentFactory->createLinkButton()
->setHref((string)$this->uriBuilder->buildUriFromRoute(
'record_edit',
[
'edit' => ['sys_redirect' => ['new']],
'module' => 'qrcodes',
'defVals' => [
'sys_redirect' => [
'redirect_type' => Demand::QRCODE_REDIRECT_TYPE,
],
],
'returnUrl' => (string)$this->uriBuilder->buildUriFromRoute('qrcodes'),
]
))
->setTitle($languageService->translate('add_text', 'redirects.modules.qrcodes'))
->setShowLabelText(true)
->setIcon($this->iconFactory->getIcon('actions-plus', IconSize::SMALL));
$view->addButtonToButtonBar($newRecordButton, ButtonBar::BUTTON_POSITION_LEFT, 10);
$view->getDocHeaderComponent()->setShortcutContext(
'qrcodes',
$languageService->translate('short_description', 'redirects.modules.qrcodes')
);
}
protected function getLanguageService(): LanguageService
{
return $GLOBALS['LANG'];
}
}
@@ -0,0 +1,123 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Redirects\Controller;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Backend\Attribute\AsController;
use TYPO3\CMS\Backend\History\RecordHistory;
use TYPO3\CMS\Backend\History\RecordHistoryRollback;
use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
use TYPO3\CMS\Core\DataHandling\Model\CorrelationId;
use TYPO3\CMS\Core\Http\JsonResponse;
use TYPO3\CMS\Core\Localization\LanguageServiceFactory;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Redirects\Service\TemporaryPermissionMutationService;
/**
* @internal
*/
#[AsController]
readonly class RecordHistoryRollbackController
{
public function __construct(
private LanguageServiceFactory $languageServiceFactory,
private RecordHistoryRollback $recordHistoryRollback,
private TemporaryPermissionMutationService $temporaryPermissionMutationService
) {}
public function revertCorrelation(ServerRequestInterface $request): ResponseInterface
{
$languageService = $this->languageServiceFactory->createFromUserPreferences($this->getBackendUser());
$revertedCorrelationTypes = [];
$correlationIds = $request->getParsedBody()['correlation_ids'] ?? [];
/** @var CorrelationId[] $correlationIds */
$correlationIds = array_map(
static function (string $correlationId) {
return CorrelationId::fromString($correlationId);
},
$correlationIds
);
foreach ($correlationIds as $correlationId) {
$type = $correlationId->getAspects()[1] ?? null;
if ($type !== null) {
$revertedCorrelationTypes[] = $type;
}
$this->rollBackCorrelation($correlationId);
}
$result = [
'status' => 'error',
'title' => $languageService->sL('LLL:EXT:redirects/Resources/Private/Language/locallang_slug_service.xlf:redirects_error_title'),
'message' => $languageService->sL('LLL:EXT:redirects/Resources/Private/Language/locallang_slug_service.xlf:redirects_error_message'),
];
if (in_array('redirect', $revertedCorrelationTypes, true)) {
$result = [
'status' => 'ok',
'title' => $languageService->sL('LLL:EXT:redirects/Resources/Private/Language/locallang_slug_service.xlf:revert_redirects_success_title'),
'message' => $languageService->sL('LLL:EXT:redirects/Resources/Private/Language/locallang_slug_service.xlf:revert_redirects_success_message'),
];
if (in_array('slug', $revertedCorrelationTypes, true)) {
$result = [
'status' => 'ok',
'title' => $languageService->sL('LLL:EXT:redirects/Resources/Private/Language/locallang_slug_service.xlf:revert_update_success_title'),
'message' => $languageService->sL('LLL:EXT:redirects/Resources/Private/Language/locallang_slug_service.xlf:revert_update_success_message'),
];
}
}
return new JsonResponse($result);
}
protected function rollBackCorrelation(CorrelationId $correlationId): void
{
$currentUserId = $this->getBackendUser()->getUserId();
$historyEntries = GeneralUtility::makeInstance(RecordHistory::class)->findEventsForCorrelation((string)$correlationId);
// Verify the correlation belongs to the current user before allowing rollback.
// All entries sharing a correlation_id are written by the same user, so checking
// any one entry is sufficient; we use the first (most recent) for the guard.
$firstEntry = reset($historyEntries);
if ($firstEntry === false || (int)$firstEntry['userid'] !== $currentUserId) {
return;
}
// Temporary add permissions to the user to perform the action.
// Store if we need to revert those changes after the actions.
$addedTableSelect = $this->temporaryPermissionMutationService->addTableSelect();
$addedTableModify = $this->temporaryPermissionMutationService->addTableModify();
foreach ($historyEntries as $recordHistoryEntry) {
$element = $recordHistoryEntry['tablename'] . ':' . $recordHistoryEntry['recuid'];
$tempRecordHistory = GeneralUtility::makeInstance(RecordHistory::class, $element);
$tempRecordHistory->setLastHistoryEntryNumber((int)$recordHistoryEntry['uid']);
$this->recordHistoryRollback->performRollback('ALL', $tempRecordHistory->getDiff($tempRecordHistory->getChangeLog()));
}
// Revert temporary permissions
if ($addedTableSelect) {
$this->temporaryPermissionMutationService->removeTableSelect();
}
if ($addedTableModify) {
$this->temporaryPermissionMutationService->removeTableModify();
}
}
private function getBackendUser(): BackendUserAuthentication
{
return $GLOBALS['BE_USER'];
}
}
@@ -0,0 +1,94 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Redirects\Controller;
use Psr\Http\Message\ResponseFactoryInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\StreamFactoryInterface;
use Symfony\Component\DependencyInjection\Attribute\Autoconfigure;
use TYPO3\CMS\Core\Localization\LanguageService;
use TYPO3\CMS\Redirects\Service\ShortUrlService;
/**
* @internal Only to be used within TYPO3. Might change in the future.
*/
#[Autoconfigure(public: true)]
readonly class ShortUrlGeneratorController
{
public function __construct(
protected ShortUrlService $shortUrlService,
protected ResponseFactoryInterface $responseFactory,
protected StreamFactoryInterface $streamFactory,
) {}
public function generate(ServerRequestInterface $request): ResponseInterface
{
$parsedBody = $request->getParsedBody();
$sourceHost = (string)($parsedBody['source_host'] ?? '');
$shortUrl = $this->shortUrlService->generateUniqueShortUrlPath($sourceHost);
if ($shortUrl === null) {
return $this->createResponse([
'success' => false,
'message' => 'Could not generate a unique short URL after multiple attempts.',
]);
}
return $this->createResponse([
'success' => true,
'shortUrl' => $shortUrl,
]);
}
public function validate(ServerRequestInterface $request): ResponseInterface
{
$parsedBody = $request->getParsedBody();
$sourceHost = (string)($parsedBody['source_host'] ?? '');
$sourcePath = (string)($parsedBody['source_path'] ?? '');
if ($sourcePath === '') {
return $this->createResponse(['isUnique' => true]);
}
// Ensure leading slash
if ($sourcePath[0] !== '/') {
$sourcePath = '/' . $sourcePath;
}
$isUnique = $this->shortUrlService->isUniqueShortUrl($sourceHost, $sourcePath);
$response = ['isUnique' => $isUnique];
if (!$isUnique) {
$response['message'] = $this->getLanguageService()
->sL('redirects.modules.short_urls:validation.duplicate_short_url');
}
return $this->createResponse($response);
}
private function getLanguageService(): LanguageService
{
return $GLOBALS['LANG'];
}
private function createResponse(array $data): ResponseInterface
{
return $this->responseFactory->createResponse()
->withHeader('Content-Type', 'application/json; charset=utf-8')
->withBody($this->streamFactory->createStream((string)json_encode($data)));
}
}
@@ -0,0 +1,156 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Redirects\Controller;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Backend\Attribute\AsController;
use TYPO3\CMS\Backend\Routing\UriBuilder;
use TYPO3\CMS\Backend\Template\Components\ButtonBar;
use TYPO3\CMS\Backend\Template\Components\ComponentFactory;
use TYPO3\CMS\Backend\Template\Components\MultiRecordSelection\Action;
use TYPO3\CMS\Backend\Template\ModuleTemplate;
use TYPO3\CMS\Backend\Template\ModuleTemplateFactory;
use TYPO3\CMS\Core\Configuration\Features;
use TYPO3\CMS\Core\Imaging\IconFactory;
use TYPO3\CMS\Core\Imaging\IconSize;
use TYPO3\CMS\Core\Localization\LanguageService;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Redirects\Repository\Demand;
use TYPO3\CMS\Redirects\Repository\RedirectRepository;
use TYPO3\CMS\Redirects\Service\ModulePaginationService;
use TYPO3\CMS\Redirects\Utility\RedirectConflict;
/**
* Lists all Short URLs in the TYPO3 Backend as a module.
*
* @internal This class is a specific TYPO3 Backend controller implementation and is not part of the Public TYPO3 API.
*/
#[AsController]
readonly class ShortUrlModuleController
{
public function __construct(
protected UriBuilder $uriBuilder,
protected IconFactory $iconFactory,
protected RedirectRepository $redirectRepository,
protected ModuleTemplateFactory $moduleTemplateFactory,
protected ComponentFactory $componentFactory,
protected ModulePaginationService $modulePaginationService,
) {}
/**
* Injects the request object for the current request and renders the overview of all Short URLs.
*/
public function handleRequest(ServerRequestInterface $request): ResponseInterface
{
$view = $this->moduleTemplateFactory->create($request);
$demand = Demand::fromRequest($request);
$redirectType = $demand->getRedirectType();
$view->setTitle(
$this->getLanguageService()->translate('title', 'redirects.modules.short_urls')
);
$view->makeDocHeaderModuleMenu();
$this->registerDocHeaderButtons($view);
$requestUri = $request->getAttribute('normalizedParams')->getRequestUri();
$languageService = $this->getLanguageService();
$pagination = $this->modulePaginationService->preparePagination($demand);
$view->assignMultiple([
'redirects' => $this->redirectRepository->findRedirectsByDemand($demand),
'hosts' => $this->redirectRepository->findHostsOfRedirects($redirectType),
'protocol' => $request->getUri()->getScheme(),
'defaultIntegrityStatus' => RedirectConflict::NO_CONFLICT,
'demand' => $demand,
'showHitCounter' => GeneralUtility::makeInstance(Features::class)->isFeatureEnabled('redirects.hitCount'),
'pagination' => $pagination,
'returnUrl' => $this->uriBuilder->buildUriFromRoute('short_urls', [
'page' => $pagination['current'],
'demand' => $demand->getParameters(),
'orderField' => $demand->getOrderField(),
'orderDirection' => $demand->getOrderDirection(),
]),
'actions' => [
new Action(
'edit',
[
'idField' => 'uid',
'tableName' => 'sys_redirect',
'returnUrl' => $requestUri,
],
'actions-open',
'LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:cm.edit'
),
new Action(
'delete',
[
'idField' => 'uid',
'tableName' => 'sys_redirect',
'title' => $languageService->translate('delete.title', 'redirects.modules.short_urls'),
'content' => $languageService->translate('delete.message', 'redirects.modules.short_urls'),
'ok' => $languageService->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:cm.delete'),
'cancel' => $languageService->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.cancel'),
'returnUrl' => $requestUri,
],
'actions-edit-delete',
'LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:cm.delete'
),
],
]);
return $view->renderResponse('ShortUrl/Overview');
}
/**
* Create document header buttons for Short URLs
*/
protected function registerDocHeaderButtons(ModuleTemplate $view): void
{
$languageService = $this->getLanguageService();
// Create new
$newRecordButton = $this->componentFactory->createLinkButton()
->setHref((string)$this->uriBuilder->buildUriFromRoute(
'record_edit',
[
'edit' => ['sys_redirect' => ['new']],
'module' => 'short_urls',
'defVals' => [
'sys_redirect' => [
'redirect_type' => Demand::SHORT_URL_REDIRECT_TYPE,
],
],
'returnUrl' => (string)$this->uriBuilder->buildUriFromRoute('short_urls'),
]
))
->setTitle($languageService->translate('add_text', 'redirects.modules.short_urls'))
->setShowLabelText(true)
->setIcon($this->iconFactory->getIcon('actions-plus', IconSize::SMALL));
$view->addButtonToButtonBar($newRecordButton, ButtonBar::BUTTON_POSITION_LEFT, 10);
$view->getDocHeaderComponent()->setShortcutContext(
'short_urls',
$languageService->translate('short_description', 'redirects.modules.short_urls')
);
}
protected function getLanguageService(): LanguageService
{
return $GLOBALS['LANG'];
}
}
+105
View File
@@ -0,0 +1,105 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license 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\Data;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
use TYPO3\CMS\Core\Cache\Frontend\FrontendInterface;
use TYPO3\CMS\Core\Exception\SiteNotFoundException;
use TYPO3\CMS\Core\Site\SiteFinder;
/**
* Data provider for source hosts in sys_redirect records
*
* @internal
*/
final readonly class SourceHostProvider
{
public function __construct(
private SiteFinder $siteFinder,
#[Autowire(service: 'cache.runtime')]
private FrontendInterface $cache,
) {}
/**
* Get all available hosts for current backend user.
*
* @return list<non-empty-string>
*/
public function getHosts(bool $includeWildcard = false): array
{
$cacheIdentifier = 'RedirectsSourceHostProvider' . ($includeWildcard ? '-wildcard' : '');
if (!$this->cache->has($cacheIdentifier)) {
$this->cache->set($cacheIdentifier, $this->filterAllowedSourceHosts($includeWildcard));
}
return $this->cache->get($cacheIdentifier);
}
/**
* @return list<non-empty-string>
*/
private function filterAllowedSourceHosts(bool $includeWildcard): array
{
$backendUser = $this->getBackendUser();
if ($includeWildcard) {
$hosts = ['*'];
} else {
$hosts = [];
}
if ($backendUser->isAdmin()) {
foreach ($this->siteFinder->getAllSites() as $site) {
foreach ($site->getAllLanguages() as $language) {
$host = $language->getBase()->getHost();
if ($host !== '' && !in_array($host, $hosts, true)) {
$hosts[] = $host;
}
}
}
} else {
foreach ($backendUser->getWebmounts() as $pageId) {
try {
$site = $this->siteFinder->getSiteByPageId($pageId);
foreach ($site->getAvailableLanguages($backendUser) as $language) {
$host = $language->getBase()->getHost();
if ($host !== '' && !in_array($host, $hosts, true)) {
$hosts[] = $host;
}
}
} catch (SiteNotFoundException) {
// Ignore unavailable sites
}
}
}
sort($hosts, SORT_NATURAL);
return $hosts;
}
private function getBackendUser(): BackendUserAuthentication
{
return $GLOBALS['BE_USER'];
}
}
+102
View File
@@ -0,0 +1,102 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Redirects\Evaluation;
use TYPO3\CMS\Core\Page\JavaScriptModuleInstruction;
use TYPO3\CMS\Core\Utility\PathUtility;
/**
* Triggered from DataHandler as TCA formevals hook for validation / sanitation of domain values.
*
* @internal
*/
class SourceHost
{
/**
* Returns JavaScript instruction for client side validation/evaluation
* (invoked by FormEngine when editing redirect entities).
*
* Returned `JavaScriptModuleInstruction` delegates handling to corresponding
* JavaScript module, having a method `evaluateSourceHost` that deals with that
* evaluation request.
*/
public function returnFieldJS(): JavaScriptModuleInstruction
{
return JavaScriptModuleInstruction::create('@typo3/redirects/form-engine-evaluation.js', 'FormEngineEvaluation');
}
/**
* Server-side removing of protocol on save
*
* @param string $value The field value to be evaluated
* @return string Evaluated field value
*/
public function evaluateFieldValue(string $value): string
{
// 1) Special case: * means any domain
if ($value === '*') {
return $value;
}
// 2) Check if value contains a protocol like http:// https:// etc...
if (PathUtility::hasProtocolAndScheme($value)) {
$tmp = $this->parseUrl($value);
if (!empty($tmp)) {
return $tmp;
}
}
// 3) Check domain name
// remove anything after the first "/"
$checkValue = $value;
if (str_contains($value, '/')) {
$checkValue = substr($value, 0, (int)strpos($value, '/'));
}
$validHostnameRegex = '/^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\-]*[A-Za-z0-9])$/';
if (preg_match_all($validHostnameRegex, $checkValue, $matches, PREG_SET_ORDER) !== false) {
if (!empty($matches)) {
return $checkValue;
}
}
// 4) IPv4 or IPv6
$isIP = filter_var($value, FILTER_VALIDATE_IP) === $value;
if ($isIP) {
return $value;
}
return '';
}
protected function parseUrl(string $value): string
{
$urlParts = parse_url($value);
if (!empty($urlParts['host'])) {
$value = $urlParts['host'];
// Special case IPv6 with protocol: http://[2001:0db8:85a3:08d3::0370:7344]/
// $urlParts['host'] will be [2001:0db8:85a3:08d3::0370:7344]
$ipv6Pattern = '/\[([a-zA-Z0-9:]*)\]/';
preg_match_all($ipv6Pattern, $urlParts['host'], $ipv6Matches, PREG_SET_ORDER);
if (!empty($ipv6Matches[0][1])) {
$value = $ipv6Matches[0][1];
}
}
return $value;
}
}
@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Redirects\Event;
use TYPO3\CMS\Redirects\RedirectUpdate\RedirectSourceInterface;
use TYPO3\CMS\Redirects\RedirectUpdate\SlugRedirectChangeItem;
/**
* This event is fired in the \TYPO3\CMS\Redirects\Service\SlugService after
* a redirect record has been automatically created and persisted after page
* slug change. It's mainly a pure notification event.
*
* It can be used to update redirects external in a load-balancer directly for
* example, or doing some kind of synchronization.
*/
final readonly class AfterAutoCreateRedirectHasBeenPersistedEvent
{
public function __construct(
private SlugRedirectChangeItem $slugRedirectChangeItem,
private RedirectSourceInterface $source,
private array $redirectRecord,
) {}
public function getSlugRedirectChangeItem(): SlugRedirectChangeItem
{
return $this->slugRedirectChangeItem;
}
public function getSource(): RedirectSourceInterface
{
return $this->source;
}
public function getRedirectRecord(): array
{
return $this->redirectRecord;
}
}
@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Redirects\Event;
use TYPO3\CMS\Core\Site\Entity\Site;
/**
* This event is fired in \TYPO3\CMS\Redirects\Service\IntegrityService->getAllPageUrlsForSite() to
* gather URLs of subpages for a given site.
*/
final class AfterPageUrlsForSiteForRedirectIntegrityHaveBeenCollectedEvent
{
public function __construct(
private readonly Site $site,
private array $pageUrls = [],
) {}
public function getSite(): Site
{
return $this->site;
}
public function setPageUrls(array $pageUrls): void
{
$this->pageUrls = $pageUrls;
}
public function getPageUrls(): array
{
return $this->pageUrls;
}
}
@@ -0,0 +1,85 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Redirects\Event;
/**
* This event is fired in \TYPO3\CMS\Redirects\Service\RedirectService->matchRedirect() for checked host and
* wildcard host "*".
*
* It can be used to implement a custom match method, returning a matchedRedirect record with eventually enriched
* record data.
*/
final class BeforeRedirectMatchDomainEvent
{
private ?array $matchedRedirect = null;
public function __construct(
private readonly string $domain,
private readonly string $path,
private readonly string $query,
private readonly string $matchDomainName,
) {}
/**
* @return string Request domain name (host)
*/
public function getDomain(): string
{
return $this->domain;
}
/**
* @return string Request path
*/
public function getPath(): string
{
return $this->path;
}
/**
* @return string Request query parameters
*/
public function getQuery(): string
{
return $this->query;
}
/**
* @return string Domain name which should be checked, and `getRedirects()` items are provided for
*/
public function getMatchDomainName(): string
{
return $this->matchDomainName;
}
/**
* @return array|null Returns the matched `sys_redirect` record or null
*/
public function getMatchedRedirect(): ?array
{
return $this->matchedRedirect;
}
/**
* @param array|null $matchedRedirect Set matched `sys_redirect` record or null to clear prior set record
*/
public function setMatchedRedirect(?array $matchedRedirect): void
{
$this->matchedRedirect = $matchedRedirect;
}
}
@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Redirects\Event;
use TYPO3\CMS\Redirects\RedirectUpdate\RedirectSourceInterface;
use TYPO3\CMS\Redirects\RedirectUpdate\SlugRedirectChangeItem;
/**
* This event is fired in the \TYPO3\CMS\Redirects\Service\SlugService before
* a redirect record is persisted for changed page slug.
*
* It can be used to modify the redirect record before persisting it. This
* gives extension developers the ability to apply defaults or add custom
* values to the record.
*/
final class ModifyAutoCreateRedirectRecordBeforePersistingEvent
{
public function __construct(
private readonly SlugRedirectChangeItem $slugRedirectChangeItem,
private readonly RedirectSourceInterface $source,
private array $redirectRecord,
) {}
public function getSlugRedirectChangeItem(): SlugRedirectChangeItem
{
return $this->slugRedirectChangeItem;
}
public function getSource(): RedirectSourceInterface
{
return $this->source;
}
public function getRedirectRecord(): array
{
return $this->redirectRecord;
}
public function setRedirectRecord(array $redirectRecord): void
{
$this->redirectRecord = $redirectRecord;
}
}
@@ -0,0 +1,179 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license 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\Event;
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Core\View\ViewInterface;
use TYPO3\CMS\Redirects\Repository\Demand;
/**
* This event is fired in the \TYPO3\CMS\Redirects\Controller\ManagementController
* handleRequest() method.
*
* It can be used to further enrich view data for the management view.
*/
final class ModifyRedirectManagementControllerViewDataEvent
{
public function __construct(
private Demand $demand,
private array $redirects,
private array $hosts,
private array $statusCodes,
private array $creationTypes,
private bool $showHitCounter,
private ViewInterface $view,
private readonly ServerRequestInterface $request,
private array $integrityStatusCodes,
) {}
/**
* Return the demand object used to retrieve the redirects.
*/
public function getDemand(): Demand
{
return $this->demand;
}
/**
* Can be used to set the demand object.
*/
public function setDemand(Demand $demand): void
{
$this->demand = $demand;
}
/**
* Return the retrieved redirects.
*/
public function getRedirects(): array
{
return $this->redirects;
}
/**
* Can be used to set the redirects, for example, after enriching redirect fields.
*/
public function setRedirects(array $redirects): void
{
$this->redirects = $redirects;
}
/**
* Return the current PSR-7 request.
*/
public function getRequest(): ServerRequestInterface
{
return $this->request;
}
/**
* Returns the hosts to be used for the host filter select box.
*/
public function getHosts(): array
{
return $this->hosts;
}
/**
* Can be used to update which hosts are available in the filter select box.
*/
public function setHosts(array $hosts): void
{
$this->hosts = $hosts;
}
/**
* Returns the status codes for the filter select box.
*/
public function getStatusCodes(): array
{
return $this->statusCodes;
}
/**
* Can be used to update which status codes are available in the filter select box.
*/
public function setStatusCodes(array $statusCodes): void
{
$this->statusCodes = $statusCodes;
}
/**
* Returns creation types for the filter select box.
*/
public function getCreationTypes(): array
{
return $this->creationTypes;
}
/**
* Can be used to update which creation types are available in the filter select box.
*/
public function setCreationTypes(array $creationTypes): void
{
$this->creationTypes = $creationTypes;
}
/**
* Returns, if hit counter should be displayed.
*/
public function getShowHitCounter(): bool
{
return $this->showHitCounter;
}
/**
* Can be used to manage, if the hit counter should be displayed.
*/
public function setShowHitCounter(bool $showHitCounter): void
{
$this->showHitCounter = $showHitCounter;
}
/**
* Returns the current view object, without controller data assigned yet.
*/
public function getView(): ViewInterface
{
return $this->view;
}
/**
* Can be used to assign additional data to the view.
*/
public function setView(ViewInterface $view): void
{
$this->view = $view;
}
/**
* Returns all integrity status codes.
*/
public function getIntegrityStatusCodes(): array
{
return $this->integrityStatusCodes;
}
/**
* Allows to set integrity status codes. It can be used to filter for integrity status codes.
*/
public function setIntegrityStatusCodes(array $integrityStatusCodes): void
{
$this->integrityStatusCodes = $integrityStatusCodes;
}
}
@@ -0,0 +1,141 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Redirects\Event;
/**
* This event is fired in \TYPO3\CMS\Redirects\Service\IntegrityService->checkRedirectTargetIntegrity()
* for each redirect record.
*
* It can be used to perform custom validation on redirect targets and flag broken or invalid targets.
*/
final class RedirectIntegrityCheckEvent
{
private ?string $integrityStatus = null;
/**
* @param array<string, string|int|float|null> $redirect
*/
public function __construct(
private readonly array $redirect,
) {}
/**
* @return array<string, string|int|float|null>
*/
public function getRedirect(): array
{
return $this->redirect;
}
public function getUid(): int
{
return $this->redirect['uid'];
}
public function getPid(): int
{
return $this->redirect['pid'];
}
public function getDeleted(): bool
{
return ((int)($this->redirect['deleted'] ?? 0)) === 1;
}
public function getDisabled(): bool
{
return ((int)($this->redirect['disabled'] ?? 0)) === 1;
}
public function getSourceHost(): string
{
return $this->redirect['source_host'];
}
public function getSourcePath(): string
{
return $this->redirect['source_path'];
}
public function getIsRegExp(): bool
{
return ((int)($this->redirect['is_regexp'] ?? 0)) === 1;
}
public function getProtected(): bool
{
return ((int)($this->redirect['protected'] ?? 0)) === 1;
}
public function getForceHttps(): bool
{
return ((int)($this->redirect['force_https'] ?? 0)) === 1;
}
public function getRespectQueryParameters(): bool
{
return ((int)($this->redirect['respect_query_parameters'] ?? 0)) === 1;
}
public function getKeepQueryParameters(): bool
{
return ((int)($this->redirect['keep_query_parameters'] ?? 0)) === 1;
}
public function getTarget(): string
{
return (string)($this->redirect['target'] ?? '');
}
public function getTargetStatusCode(): int
{
return (int)$this->redirect['target_statuscode'];
}
public function getCreationType(): int
{
return (int)$this->redirect['creation_type'];
}
public function getOriginalIntegrityStatus(): string
{
return $this->redirect['integrity_status'];
}
/**
* Be aware that this has been possible set by another earlier PSR-14 event listener already.
* Could be any of the {@see RedirectConflict} constants, a custom value or `NULL`. In case
* of `NULL` no further handlinge are processed or regonized as conflict during the integrity
* checks.
*
* This is not initialized with the `sys_redirect.integirty_status` value.
*/
public function getIntegrityStatus(): ?string
{
return $this->integrityStatus;
}
/**
* Set the integrity status, could be one of the {@see RedirectConflict} constants, a custom value or `NULL`.
* In case of `NULL` no further handlinge are processed or regonized as conflict during the integrity checks.
*/
public function setIntegrityStatus(?string $integrityStatus): void
{
$this->integrityStatus = $integrityStatus;
}
}
+70
View File
@@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Redirects\Event;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\UriInterface;
/**
* This event is fired in the \TYPO3\CMS\Redirects\Http\Middleware\RedirectHandler
* middleware when a request matches a configured redirect.
*
* It can be used to further process the matched redirect and
* to adjust the PSR-7 Response. It furthermore allows to influence Core
* functionality, for example the hit count increment.
*/
final class RedirectWasHitEvent
{
public function __construct(
private readonly ServerRequestInterface $request,
private ResponseInterface $response,
private array $matchedRedirect,
private readonly UriInterface $targetUrl
) {}
public function getRequest(): ServerRequestInterface
{
return $this->request;
}
public function getTargetUrl(): UriInterface
{
return $this->targetUrl;
}
public function setMatchedRedirect(array $matchedRedirect): void
{
$this->matchedRedirect = $matchedRedirect;
}
public function getMatchedRedirect(): array
{
return $this->matchedRedirect;
}
public function setResponse(ResponseInterface $response): void
{
$this->response = $response;
}
public function getResponse(): ResponseInterface
{
return $this->response;
}
}
@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Redirects\Event;
use TYPO3\CMS\Redirects\RedirectUpdate\SlugRedirectChangeItem;
/**
* This event is fired in the \TYPO3\CMS\Redirects\RedirectUpdate\SlugRedirectChangeItemFactory
* factory if a new SlugRedirectChangeItem is created.
*
* It can be used to add additional sources, remove sources or completely remove the change item itself.
* A source must implement the RedirectSourceInterface, and for each source a redirect record is created
* later in the SlugService. If the SlugRedirectChangeItem is set to null, no further action is executed
* for this slug change.
*/
final class SlugRedirectChangeItemCreatedEvent
{
public function __construct(
private SlugRedirectChangeItem $slugRedirectChangeItem
) {}
public function getSlugRedirectChangeItem(): SlugRedirectChangeItem
{
return $this->slugRedirectChangeItem;
}
public function setSlugRedirectChangeItem(SlugRedirectChangeItem $slugRedirectChangeItem): void
{
$this->slugRedirectChangeItem = $slugRedirectChangeItem;
}
}
@@ -0,0 +1,137 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Redirects\EventListener;
use TYPO3\CMS\Core\Attribute\AsEventListener;
use TYPO3\CMS\Core\Context\Context;
use TYPO3\CMS\Core\Context\VisibilityAspect;
use TYPO3\CMS\Core\DataHandling\PageDoktypeRegistry;
use TYPO3\CMS\Core\Routing\InvalidRouteArgumentsException;
use TYPO3\CMS\Core\Routing\RouterInterface;
use TYPO3\CMS\Core\Routing\UnableToLinkToPageException;
use TYPO3\CMS\Core\Site\Entity\Site;
use TYPO3\CMS\Core\Site\Entity\SiteLanguage;
use TYPO3\CMS\Redirects\Event\SlugRedirectChangeItemCreatedEvent;
use TYPO3\CMS\Redirects\RedirectUpdate\PageTypeSource;
use TYPO3\CMS\Redirects\RedirectUpdate\PlainSlugReplacementRedirectSource;
use TYPO3\CMS\Redirects\RedirectUpdate\RedirectSourceCollection;
use TYPO3\CMS\Redirects\RedirectUpdate\RedirectSourceInterface;
/**
* Event listener which build a source using site router for page type "0" and add it as PageTypeSource to
* the collection. Eventually existing PlainSlugReplacement source will be removed, if it would provide the
* same source definition as the generated PageTypeSource.
*
* @internal only to be used within redirects, not part of TYPO3 Core API.
*/
final readonly class AddPageTypeZeroSource
{
public function __construct(
private PageDoktypeRegistry $pageDoktypeRegistry,
private Context $context,
) {}
#[AsEventListener(identifier: 'redirects-add-page-type-zero-source', after: 'redirects-add-plain-slug-replacement-source')]
public function __invoke(SlugRedirectChangeItemCreatedEvent $event): void
{
// Create full resolved uri for page type zero.
$changeItem = $event->getSlugRedirectChangeItem();
// Do not create a redirect source for ignored doktypes
if (!$this->pageDoktypeRegistry->isPageTypeViewable((int)($changeItem->getOriginal()['doktype'] ?? 0))) {
return;
}
// @todo Consider to make creation of source skip-able if page is hidden or scheduled. Should be in sync with
// PlainSlugReplacementSource creation. Eventually configurable OR omitting in SlugRedirectChangeItemFactory.
try {
$pageTypeZeroSource = $this->createPageTypeZeroSource(
$changeItem->getPageId(),
$changeItem->getSite(),
$changeItem->getSiteLanguage(),
);
} catch (UnableToLinkToPageException) {
// Could not properly link to page. Nothing left to do, so return directly.
return;
}
$sources = $changeItem->getSourcesCollection()->all();
// If page type zero source results in the same uri, plain slug replacement source is removed. This avoids
// the creation of duplicated redirects. PageTypeSource is taken as stronger match and therefor used.
$sources = array_filter($sources, fn($source) => !$this->sourceEqualsPageTypeZeroSource($source, $pageTypeZeroSource));
$sources[] = $pageTypeZeroSource;
$changeItem = $changeItem->withSourcesCollection(new RedirectSourceCollection(...array_values($sources)));
$event->setSlugRedirectChangeItem($changeItem);
}
private function sourceEqualsPageTypeZeroSource(RedirectSourceInterface $source, PageTypeSource $pageTypeZeroSource): bool
{
return $source instanceof PlainSlugReplacementRedirectSource
&& $source->getHost() === $pageTypeZeroSource->getHost()
&& rtrim($source->getPath(), '/') === rtrim($pageTypeZeroSource->getPath(), '/');
}
private function createPageTypeZeroSource(int $pageUid, Site $site, SiteLanguage $siteLanguage): PageTypeSource
{
try {
$context = $this->getAdjustedContext();
$uri = $site->getRouter($context)->generateUri(
$pageUid,
[
'_language' => $siteLanguage,
'type' => 0,
],
'',
RouterInterface::ABSOLUTE_URL
);
return new PageTypeSource(
$uri->getHost() ?: '*',
$uri->getPath(),
0,
[],
);
} catch (\InvalidArgumentException|InvalidRouteArgumentsException $e) {
throw new UnableToLinkToPageException(
sprintf(
'The link to the page with ID "%d" and type "%d" could not be generated: %s',
$pageUid,
0,
$e->getMessage()
),
1671639962,
$e
);
}
}
/**
* Returns the adjusted current context with modified visibility settings to build
* source url for hidden or scheduled pages.
*/
private function getAdjustedContext(): Context
{
$adjustedVisibility = new VisibilityAspect(
true,
true,
false,
true,
);
$context = clone $this->context;
$context->setAspect('visibility', $adjustedVisibility);
return $context;
}
}
@@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Redirects\EventListener;
use TYPO3\CMS\Core\Attribute\AsEventListener;
use TYPO3\CMS\Core\DataHandling\PageDoktypeRegistry;
use TYPO3\CMS\Redirects\Event\SlugRedirectChangeItemCreatedEvent;
use TYPO3\CMS\Redirects\RedirectUpdate\PlainSlugReplacementRedirectSource;
use TYPO3\CMS\Redirects\RedirectUpdate\RedirectSourceCollection;
/**
* Event listener to create plain slug replacement.
* @internal Internal use for ext:redirects, not part of Public API. May vanish at any given time.
*/
final readonly class AddPlainSlugReplacementSource
{
public function __construct(
private PageDoktypeRegistry $doktypeRegistry,
) {}
#[AsEventListener('redirects-add-plain-slug-replacement-source')]
public function __invoke(SlugRedirectChangeItemCreatedEvent $event): void
{
$changeItem = $event->getSlugRedirectChangeItem();
// Do not create a redirect source for ignored doktypes
if (!$this->doktypeRegistry->isPageTypeViewable((int)($changeItem->getOriginal()['doktype'] ?? 0))) {
return;
}
// @todo Consider to make creation of source skip-able if page is hidden or scheduled. Should be in sync with
// AddPageTypeZeroSource creation. Eventually configurable OR omitting in SlugRedirectChangeItemFactory.
// We create a plain slug replacement source, which mirrors the behaviour since first implementation. This
// may vanish anytime. Introducing an event here opens up the possibility to add custom source definitions, for
// example doing a real URI building to cover route decorators and enhancers, or creating redirects for more
// than only one source.
$changeItem = $changeItem->withSourcesCollection(
new RedirectSourceCollection(
new PlainSlugReplacementRedirectSource(
host: $changeItem->getSiteLanguage()->getBase()->getHost() ?: '*',
path: rtrim($changeItem->getSiteLanguage()->getBase()->getPath(), '/') . $changeItem->getOriginal()['slug'],
targetLinkParameters: []
),
...$changeItem->getSourcesCollection()->all()
)
);
$event->setSlugRedirectChangeItem($changeItem);
}
}
@@ -0,0 +1,102 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Redirects\EventListener;
use TYPO3\CMS\Core\Attribute\AsEventListener;
use TYPO3\CMS\Core\Database\Connection;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Schema\Capability\TcaSchemaCapability;
use TYPO3\CMS\Core\Schema\TcaSchemaFactory;
use TYPO3\CMS\Core\Site\Entity\Site;
use TYPO3\CMS\Redirects\Event\AfterPageUrlsForSiteForRedirectIntegrityHaveBeenCollectedEvent;
/**
* Event listener to gather the slugs of all pages of a site
*
* @internal This class is not part of TYPO3 Core API.
*/
final readonly class AddUrlsForSubPagesForIntegrityCheck
{
public function __construct(
private ConnectionPool $connectionPool,
private TcaSchemaFactory $tcaSchemaFactory
) {}
#[AsEventListener('redirects-add-slugs-of-subpages')]
public function __invoke(AfterPageUrlsForSiteForRedirectIntegrityHaveBeenCollectedEvent $event): void
{
$pageUrls = $event->getPageUrls();
$pageUrls = array_merge(
$pageUrls,
$this->getSlugsOfSubPages(
$event->getSite()->getRootPageId(),
$event->getSite()
)
);
$event->setPageUrls($pageUrls);
}
/**
* Resolves the subtree of a page and returns its slugs for language $languageId.
*/
private function getSlugsOfSubPages(int $pageId, Site $site): array
{
$pageUrls = [[]];
$schema = $this->tcaSchemaFactory->get('pages');
$languageCapability = $schema->getCapability(TcaSchemaCapability::Language);
$queryBuilder = $this->connectionPool
->getQueryBuilderForTable('pages');
$queryBuilder
->select('uid', 'slug', $languageCapability->getLanguageField()->getName())
->from('pages')
->where(
$queryBuilder->expr()->eq('pid', $queryBuilder->createNamedParameter($pageId, Connection::PARAM_INT)),
);
$result = $queryBuilder->executeQuery();
while ($row = $result->fetchAssociative()) {
// @todo Considering only page slug is not complete, as it does not matches redirects with file extension,
// for ex. if PageTypeSuffix routeEnhancer are used and redirects are created based on that.
$slug = ltrim($row['slug'] ?? '', '/');
$languageId = (int)$row[$languageCapability->getLanguageField()->getName()];
try {
$siteLanguage = $site->getLanguageById($languageId);
} catch (\InvalidArgumentException) {
// skip invalid languages which might occur due to previous changes in site configuration
continue;
}
// empty slugs should to occur here, but to be sure we skip them here, as they were already handled.
if ($slug === '') {
continue;
}
$pageUrls[] = [rtrim((string)$siteLanguage->getBase(), '/') . '/' . $slug];
// only traverse for pages of default language (as even translated pages contain pid of parent in default language)
if ($languageId === 0) {
$pageUrls[] = $this->getSlugsOfSubPages((int)$row['uid'], $site);
}
}
return array_merge(...$pageUrls);
}
}
@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Redirects\EventListener;
use TYPO3\CMS\Backend\Controller\Event\AfterBackendPageRenderEvent;
use TYPO3\CMS\Core\Attribute\AsEventListener;
use TYPO3\CMS\Core\Page\PageRenderer;
/**
* @internal redirects internal usage, not part of public API.
*/
final readonly class AfterBackendPageRendererEventListener
{
public function __construct(
private PageRenderer $pageRenderer
) {}
#[AsEventListener('redirects-after-backend-page-renderer-event')]
public function __invoke(AfterBackendPageRenderEvent $event): void
{
$this->pageRenderer->loadJavaScriptModule('@typo3/redirects/event-handler.js');
}
}
@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Redirects\EventListener;
use TYPO3\CMS\Core\Attribute\AsEventListener;
use TYPO3\CMS\Core\Configuration\Features;
use TYPO3\CMS\Core\Database\Connection;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Redirects\Event\RedirectWasHitEvent;
/**
* Event listener to increment a matched redirect records' hit count
*/
final readonly class IncrementHitCount
{
public function __construct(private Features $features) {}
#[AsEventListener('redirects-increment-hit-count')]
public function __invoke(RedirectWasHitEvent $event): void
{
$matchedRedirect = $event->getMatchedRedirect();
if ($matchedRedirect['disable_hitcount']
|| !$this->features->isFeatureEnabled('redirects.hitCount')
) {
// Early return in case hit count is disabled
return;
}
$queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('sys_redirect');
$queryBuilder
->update('sys_redirect')
->where(
$queryBuilder->expr()->eq('uid', $queryBuilder->createNamedParameter($matchedRedirect['uid'], Connection::PARAM_INT))
)
->set('hitcount', $queryBuilder->quoteIdentifier('hitcount') . '+1', false)
->set('lasthiton', $GLOBALS['EXEC_TIME'])
->executeStatement();
}
}
@@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Redirects\EventListener;
use TYPO3\CMS\Backend\Routing\UriBuilder;
use TYPO3\CMS\Backend\Template\Components\ComponentFactory;
use TYPO3\CMS\Backend\Template\Components\ModifyButtonBarEvent;
use TYPO3\CMS\Core\Attribute\AsEventListener;
use TYPO3\CMS\Core\Imaging\IconFactory;
use TYPO3\CMS\Core\Imaging\IconSize;
use TYPO3\CMS\Core\Localization\LanguageService;
use TYPO3\CMS\Redirects\Repository\Demand;
#[AsEventListener]
readonly class QrCodeNewDocHeaderButton
{
public function __construct(
private UriBuilder $uriBuilder,
private ComponentFactory $componentFactory,
private IconFactory $iconFactory,
) {}
public function __invoke(ModifyButtonBarEvent $event): void
{
$buttons = $event->getButtons();
$request = $event->getRequest();
// Overwrite the "new" button only if there is already one in
// the qrcodes module. This way the show/hide logic is re-used
if (!(($buttons['left'][4][0] ?? false) && ($request->getQueryParams()['module'] ?? '') === 'qrcodes')) {
return;
}
$newQrCodeUrl = (string)$this->uriBuilder->buildUriFromRoute(
'record_edit',
[
'edit' => ['sys_redirect' => ['new']],
'module' => 'qrcodes',
'defVals' => [
'sys_redirect' => [
'redirect_type' => Demand::QRCODE_REDIRECT_TYPE,
],
],
'returnUrl' => (string)$this->uriBuilder->buildUriFromRoute('qrcodes'),
]
);
$languageService = $this->getLanguageService();
$newRecordButton = $this->componentFactory->createLinkButton()
->setHref($newQrCodeUrl)
->setTitle($languageService->sL('LLL:EXT:core/Resources/Private/Language/locallang_common.xlf:new'))
->setShowLabelText(true)
->setIcon($this->iconFactory->getIcon('actions-plus', IconSize::SMALL));
// Overwrite the default "new" button
$buttons['left'][4][0] = $newRecordButton;
$event->setButtons($buttons);
}
protected function getLanguageService(): LanguageService
{
return $GLOBALS['LANG'];
}
}
@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Redirects\EventListener;
use TYPO3\CMS\Backend\History\Event\AfterHistoryRollbackFinishedEvent;
use TYPO3\CMS\Backend\History\Event\BeforeHistoryRollbackStartEvent;
use TYPO3\CMS\Core\Attribute\AsEventListener;
use TYPO3\CMS\Redirects\Hooks\DataHandlerSlugUpdateHook;
class RecordHistoryRollbackEventsListener
{
#[AsEventListener('redirects-enable-hook')]
public function afterHistoryRollbackFinishedEvent(AfterHistoryRollbackFinishedEvent $event): void
{
// Re-Enable hook to after rollback finished
$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_tcemain.php']['processDatamapClass']['redirects']
= DataHandlerSlugUpdateHook::class;
}
#[AsEventListener('redirects-disable-hook')]
public function beforeHistoryRollbackStartEvent(BeforeHistoryRollbackStartEvent $event): void
{
// Disable hook to prevent slug change again on rollback
unset($GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_tcemain.php']['processDatamapClass']['redirects']);
}
}
@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Redirects\EventListener;
use TYPO3\CMS\Backend\Form\Event\ModifyEditFormUserAccessEvent;
use TYPO3\CMS\Core\Attribute\AsEventListener;
use TYPO3\CMS\Redirects\Security\RedirectPermissionGuard;
/**
* @internal
*/
final readonly class RedirectEditPermissionGuard
{
public function __construct(
private RedirectPermissionGuard $redirectPermissionGuard,
) {}
#[AsEventListener('redirect-edit-permission-guard')]
public function __invoke(ModifyEditFormUserAccessEvent $event): void
{
if ($event->getTableName() !== 'sys_redirect' || $event->getCommand() === 'new') {
return;
}
if (!$this->redirectPermissionGuard->isAllowedRedirect($event->getDatabaseRow())) {
$event->denyUserAccess();
}
}
}
@@ -0,0 +1,85 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Redirects\EventListener;
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Backend\Routing\UriBuilder;
use TYPO3\CMS\Backend\Template\Components\ComponentFactory;
use TYPO3\CMS\Backend\Template\Components\ModifyButtonBarEvent;
use TYPO3\CMS\Core\Attribute\AsEventListener;
use TYPO3\CMS\Core\Imaging\IconFactory;
use TYPO3\CMS\Core\Imaging\IconSize;
use TYPO3\CMS\Core\Localization\LanguageService;
use TYPO3\CMS\Redirects\Repository\Demand;
#[AsEventListener(identifier: 'typo3/cms-redirects/short-url-new-doc-header-button')]
readonly class ShortUrlNewDocHeaderButtonEventListener
{
public function __construct(
private UriBuilder $uriBuilder,
private ComponentFactory $componentFactory,
private IconFactory $iconFactory,
) {}
public function __invoke(ModifyButtonBarEvent $event): void
{
$buttons = $event->getButtons();
$request = $this->getRequest();
// Overwrite the "new" button only if there is already one in
// the Short URLs module. This way the show/hide logic is re-used
if (!(($buttons['left'][4][0] ?? false) && ($request->getQueryParams()['module'] ?? '') === 'short_urls')) {
return;
}
$newShortUrlUrl = (string)$this->uriBuilder->buildUriFromRoute(
'record_edit',
[
'edit' => ['sys_redirect' => ['new']],
'module' => 'short_urls',
'defVals' => [
'sys_redirect' => [
'redirect_type' => Demand::SHORT_URL_REDIRECT_TYPE,
],
],
'returnUrl' => (string)$this->uriBuilder->buildUriFromRoute('short_urls'),
]
);
$languageService = $this->getLanguageService();
$newRecordButton = $this->componentFactory->createLinkButton()
->setHref($newShortUrlUrl)
->setTitle($languageService->sL('LLL:EXT:core/Resources/Private/Language/locallang_common.xlf:new'))
->setShowLabelText(true)
->setIcon($this->iconFactory->getIcon('actions-plus', IconSize::SMALL));
// Overwrite the default "new" button
$buttons['left'][4][0] = $newRecordButton;
$event->setButtons($buttons);
}
private function getRequest(): ServerRequestInterface
{
return $GLOBALS['TYPO3_REQUEST'];
}
private function getLanguageService(): LanguageService
{
return $GLOBALS['LANG'];
}
}
+63
View File
@@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Redirects\Form\Element;
use TYPO3\CMS\Backend\Form\Element\AbstractFormElement;
use TYPO3\CMS\Core\Page\JavaScriptModuleInstruction;
use TYPO3\CMS\Core\Utility\MathUtility;
/**
* @internal This is a specific hook implementation and is not considered part of the Public TYPO3 API.
*/
class QrCodeElement extends AbstractFormElement
{
public function render(): array
{
$resultArray = $this->initializeResultArray();
$databaseRow = $this->data['databaseRow'] ?? [];
if ($this->data['command'] !== 'edit') {
// QR code can only be displayed on edit
return [];
}
$languageService = $this->getLanguageService();
$sourceHost = $databaseRow['source_host'] ?? '';
$sourcePath = $databaseRow['source_path'] ?? '';
if ($sourceHost && $sourcePath) {
$resultArray['javaScriptModules'][] = JavaScriptModuleInstruction::create('@typo3/backend/element/qrcode-element.js');
$resultArray['html'] = '
<div class="form-control-wrap" style="max-width: ' . $this->formMaxWidth(MathUtility::forceIntegerInRange($this->data['parameterArray']['fieldConf']['config']['size'] ?? $this->defaultInputWidth, $this->minimumInputWidth, $this->maxInputWidth)) . 'px">
<span class="form-label">
' . htmlspecialchars($languageService->translate('sys_redirect.redirect_type.qr_code', 'redirects.db')) . '
</span>
<div class="card mb-0">
<div class="card-body">
<div class="text-center">
<typo3-qrcode class="text-start" content="https://' . $sourceHost . $sourcePath . '" size="large" show-download=""></typo3-qrcode>
</div>
</div>
</div>
</div>
';
} else {
$resultArray['html'] = '<div class="alert alert-warning">' . htmlspecialchars($languageService->translate('no_qrcode', 'redirects.messages')) . '</div>';
}
return $resultArray;
}
}
@@ -0,0 +1,84 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Redirects\Form\Element;
use TYPO3\CMS\Backend\Backend\Avatar\Avatar;
use TYPO3\CMS\Backend\Form\Element\AbstractFormElement;
use TYPO3\CMS\Backend\Utility\BackendUtility;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* @internal This is a concrete implementation only, and not part of TYPO3 Core API.
*/
final class RenderCreationInformation extends AbstractFormElement
{
public function render(): array
{
$resultArray = $this->initializeResultArray();
$databaseRow = $this->data['databaseRow'] ?? [];
if ($this->data['command'] !== 'edit') {
// Created on / by can only be displayed on edit - new records are obviously not created yet
return [];
}
$userId = (int)($databaseRow['createdby'] ?? 0);
$timestamp = (int)($databaseRow['createdon'] ?? null)?->getTimestamp();
$backendUser = BackendUtility::getRecord('be_users', (int)($databaseRow['createdby'] ?? 0));
$avatarHtml = '';
if (!empty($backendUser)) {
$avatar = GeneralUtility::makeInstance(Avatar::class);
$avatarHtml = $avatar->render($backendUser, 32, true);
}
$realName = (string)($backendUser['realName'] ?? '');
$userName = (string)($backendUser['username'] ?? '');
if ($realName !== '') {
$userHtml = '<strong>' . htmlspecialchars($realName) . '</strong> <span class="text-variant">(' . htmlspecialchars($userName) . ')</span>';
} elseif ($userName !== '') {
$userHtml = '<strong>' . htmlspecialchars($userName) . '</strong>';
} elseif ($userId > 0) {
$userHtml = '<strong><i>' . htmlspecialchars($this->getLanguageService()->sL('LLL:EXT:backend/Resources/Private/Language/locallang_show_rechis.xlf:userNotFound')) . '</i></strong>';
} else {
$userHtml = '<strong><i>' . htmlspecialchars($this->getLanguageService()->sL('LLL:EXT:redirects/Resources/Private/Language/locallang.xlf:not_tracked')) . '</i></strong>';
}
$html = [];
$html[] = '<span class="form-label">';
$html[] = htmlspecialchars($this->data['parameterArray']['fieldConf']['label'] ?? '');
$html[] = '</span>';
$html[] = '<div class="form-control-wrap">';
$html[] = '<div class="form-wizards-wrap">';
$html[] = '<div class="form-wizards-item-element">';
$html[] = '<div class="formengine-field-item t3js-formengine-field-item">';
$html[] = '<div class="d-flex gap-3 mt-1">';
$html[] = $avatarHtml;
$html[] = '<div class="formengine-field-wrapper">';
$html[] = '<p class="m-0">' . $userHtml . '</p>';
if ($timestamp > 0) {
$html[] = htmlspecialchars($this->getLanguageService()->sL('LLL:EXT:redirects/Resources/Private/Language/locallang.xlf:created_on')) . ' ';
$html[] = htmlspecialchars(BackendUtility::datetime($timestamp));
}
$html[] = '</div>';
$html[] = '</div>';
$html[] = '</div>';
$html[] = '</div>';
$html[] = '</div>';
$html[] = '</div>';
$resultArray['html'] = implode(LF, $html);
return $resultArray;
}
}
+241
View File
@@ -0,0 +1,241 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license 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\Form\Element;
use TYPO3\CMS\Backend\Form\Element\AbstractFormElement;
use TYPO3\CMS\Core\Http\NormalizedParams;
use TYPO3\CMS\Core\Imaging\IconFactory;
use TYPO3\CMS\Core\Imaging\IconSize;
use TYPO3\CMS\Core\Page\JavaScriptModuleInstruction;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Custom form element that combines source_host and source_path fields
* into a single short URL input field.
*/
class ShortUrlElement extends AbstractFormElement
{
public function __construct(
private readonly IconFactory $iconFactory,
) {}
/**
* Renders a combined field displaying source_host and source_path together.
*
* @return array As defined in initializeResultArray() of AbstractNode
*/
public function render(): array
{
$resultArray = $this->initializeResultArray();
$parameterArray = $this->data['parameterArray'];
$isReadOnly = $parameterArray['fieldConf']['config']['readOnly'] ?? false;
// Render label and field information for the short_url field
$fieldId = 'formengine-' . md5($this->data['fieldName']);
$renderedLabel = $this->renderLabel($fieldId);
$fieldInformationResult = $this->renderFieldInformation();
$resultArray = $this->mergeChildReturnIntoExistingResult($resultArray, $fieldInformationResult, false);
if ($isReadOnly) {
$html = $this->renderReadOnlyView();
$resultArray['javaScriptModules'][] = JavaScriptModuleInstruction::create('@typo3/backend/copy-to-clipboard.js');
} else {
$html = $this->renderEditableView($resultArray);
}
$resultArray['html'] = $renderedLabel . '
<div class="formengine-field-item t3js-formengine-field-item">
' . $fieldInformationResult['html'] . '
' . $html . '
</div>';
return $resultArray;
}
/**
* Renders the read-only view showing the complete short URL with copy button.
*/
private function renderReadOnlyView(): string
{
$row = $this->data['databaseRow'];
$sourceHost = $row['source_host'] ?? '';
$sourcePath = $row['source_path'] ?? '';
/** @var NormalizedParams $normalizedParams */
$normalizedParams = $this->data['request']->getAttribute('normalizedParams');
$scheme = $normalizedParams->isHttps() ? 'https' : 'http';
$completeUrl = $scheme . '://' . $sourceHost . $sourcePath;
$copyTitle = $this->getLanguageService()->sL('redirects.module_redirect:short_url.copy');
$html = [];
$html[] = '<div class="form-control-wrap">';
$html[] = ' <div class="form-wizards-wrap">';
$html[] = ' <div class="form-wizards-item-element">';
$html[] = ' <div class="input-group">';
$html[] = ' <span class="input-group-text">' . htmlspecialchars($completeUrl) . '</span>';
$html[] = ' <typo3-copy-to-clipboard text="' . htmlspecialchars($completeUrl) . '" class="btn btn-default" title="' . htmlspecialchars($copyTitle) . '">';
$html[] = ' ' . $this->iconFactory->getIcon('actions-clipboard', IconSize::SMALL)->render();
$html[] = ' </typo3-copy-to-clipboard>';
$html[] = ' </div>';
$html[] = ' </div>';
$html[] = ' </div>';
$html[] = '</div>';
return implode("\n", $html);
}
/**
* Renders the editable view with both source_host and source_path fields.
*/
private function renderEditableView(array &$resultArray): string
{
$row = $this->data['databaseRow'];
$processedTca = $this->data['processedTca'];
$itemName = $this->data['parameterArray']['itemFormElName'];
$shortUrlFieldName = $this->data['fieldName'];
$sourceHostConfig = $processedTca['columns']['source_host']['config'] ?? [];
$sourcePathConfig = $processedTca['columns']['source_path']['config'] ?? [];
$sourceHostName = str_replace('[' . $shortUrlFieldName . ']', '[source_host]', $itemName);
$sourcePathName = str_replace('[' . $shortUrlFieldName . ']', '[source_path]', $itemName);
$sourceHostValue = $row['source_host'] ?? '';
$sourcePathValue = $row['source_path'] ?? '';
$sourceHostHtml = $this->renderSourceHostField($sourceHostName, $sourceHostValue, $sourceHostConfig);
$sourcePathHtml = $this->renderSourcePathField($sourcePathName, $sourcePathValue, $sourcePathConfig);
$fieldControlResult = $this->renderFieldControl();
$resultArray = $this->mergeChildReturnIntoExistingResult($resultArray, $fieldControlResult, false);
$resultArray['javaScriptModules'][] = JavaScriptModuleInstruction::create('@typo3/backend/element/combobox-element.js');
$html = [];
$html[] = '<div class="form-control-wrap">';
$html[] = ' <div class="form-wizards-wrap">';
$html[] = ' <div class="form-wizards-item-element">';
$html[] = ' <div class="input-group short-url-input-group">';
$html[] = ' ' . $sourceHostHtml;
$html[] = ' ' . $sourcePathHtml;
$html[] = ' ' . $fieldControlResult['html'];
$html[] = ' </div>';
$html[] = ' </div>';
$html[] = ' </div>';
$html[] = '</div>';
return implode("\n", $html);
}
/**
* Renders the source_host field as a combobox with value picker items.
*/
private function renderSourceHostField(string $name, string $value, array $config): string
{
$attributes = [
'type' => 'text',
'name' => $name,
'value' => $value,
'class' => 'form-control',
'data-formengine-input-name' => $name,
];
// Add required validation from config
if (!empty($config['required'])) {
$attributes['required'] = 'required';
$attributes['data-formengine-validation-rules'] = '[{"type":"required"}]';
}
// Add eval rules from config
if (!empty($config['eval'])) {
$attributes['data-formengine-input-params'] = json_encode([
'field' => $name,
'evalList' => $config['eval'],
]);
}
// Build combobox with value picker items
$html = '<typo3-backend-combobox>';
$html .= '<input ' . GeneralUtility::implodeAttributes($attributes, true) . ' />';
// Add value picker items if available
if (isset($config['valuePicker']['items'])) {
foreach ($config['valuePicker']['items'] as $item) {
$itemValue = $item['value'] ?? '';
$itemLabel = $item['label'] ?? $itemValue;
$html .= '<typo3-backend-combobox-choice value="' . htmlspecialchars($itemValue) . '">';
$html .= htmlspecialchars($this->getLanguageService()->sL($itemLabel));
$html .= '</typo3-backend-combobox-choice>';
}
}
$html .= '</typo3-backend-combobox>';
return $html;
}
/**
* Renders the source_path field as an input with validation.
*/
private function renderSourcePathField(string $name, string $value, array $config): string
{
$attributes = [
'type' => 'text',
'name' => $name,
'value' => $value,
'class' => 'form-control form-control-clearable t3js-clearable',
'data-formengine-input-name' => $name,
];
if (!empty($config['size'])) {
$attributes['size'] = (string)$config['size'];
}
if (!empty($config['max'])) {
$attributes['maxlength'] = (string)$config['max'];
}
if (!empty($config['required'])) {
$attributes['required'] = 'required';
}
if (!empty($config['placeholder'])) {
$placeholder = $this->getLanguageService()->sL($config['placeholder']);
if ($placeholder !== '') {
$attributes['placeholder'] = $placeholder;
}
}
if (!empty($config['eval'])) {
$attributes['data-formengine-input-params'] = json_encode([
'field' => $name,
'evalList' => $config['eval'],
]);
}
$validationRules = [];
if (!empty($config['required'])) {
$validationRules[] = ['type' => 'required'];
}
if (!empty($validationRules)) {
$attributes['data-formengine-validation-rules'] = json_encode($validationRules);
}
return '<input ' . GeneralUtility::implodeAttributes($attributes, true) . ' />';
}
}
@@ -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\Form\FieldControl;
use TYPO3\CMS\Backend\Form\AbstractNode;
use TYPO3\CMS\Core\Page\JavaScriptModuleInstruction;
use TYPO3\CMS\Core\Utility\StringUtility;
/**
* Renders a widget to generate a random Short URL.
*
* This is typically used in combination with TCA renderType=shortUrl,
* but can be potentially used with other field input types as well.
*
* @internal This is still a bit experimental and may change.
*/
class ShortUrlGenerator extends AbstractNode
{
public function render(): array
{
$options = $this->data['renderData']['fieldControlOptions'];
$itemName = (string)$this->data['parameterArray']['itemFormElName'];
$id = StringUtility::getUniqueId('t3js-formengine-fieldcontrol-');
// Handle options and fallback
$title = $options['title'] ?? 'LLL:EXT:redirects/Resources/Private/Language/Modules/short_urls.xlf:generate_short_url';
$linkAttributes = [
'id' => $id,
'data-item-name' => $itemName,
];
return [
'iconIdentifier' => 'actions-dice',
'title' => $title,
'linkAttributes' => $linkAttributes,
'javaScriptModules' => [
JavaScriptModuleInstruction::create('@typo3/redirects/short-url-generator.js')->instance($id),
],
];
}
}
@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Redirects\FormDataProvider;
use TYPO3\CMS\Backend\Form\FormDataProviderInterface;
use TYPO3\CMS\Redirects\Repository\Demand;
/**
* Set field config for sys_redirect of type "qrcode"
* @todo Replace this by a generic column config, like "readonlyOnPersist"!
* @internal
*/
class QrCodeSourceHostDataProvider implements FormDataProviderInterface
{
public function addData(array $result): array
{
$record = $result['databaseRow'];
// Set source_host to readyOnly
if (($record['redirect_type'] ?? '') === Demand::QRCODE_REDIRECT_TYPE && $result['command'] === 'edit') {
$result['processedTca']['columns']['source_host']['config']['readOnly'] = true;
}
return $result;
}
}
@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Redirects\FormDataProvider;
use TYPO3\CMS\Backend\Form\FormDataProviderInterface;
use TYPO3\CMS\Redirects\Repository\Demand;
/**
* Set field config for sys_redirect of type "short_url"
* @todo Replace this by a generic column config, like "readonlyOnPersist"!
* @internal
*/
class ShortUrlDataProvider implements FormDataProviderInterface
{
public function addData(array $result): array
{
$record = $result['databaseRow'];
// Set short_url to readyOnly when editing a Short URL
if (($record['redirect_type'] ?? '') === Demand::SHORT_URL_REDIRECT_TYPE && $result['command'] === 'edit') {
$result['processedTca']['columns']['short_url']['config']['readOnly'] = true;
}
return $result;
}
}
@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Redirects\FormDataProvider;
use Symfony\Component\DependencyInjection\Attribute\Autoconfigure;
use TYPO3\CMS\Backend\Form\FormDataProviderInterface;
use TYPO3\CMS\Redirects\Data\SourceHostProvider;
use TYPO3\CMS\Redirects\Repository\Demand;
/**
* Inject available domain hosts into a valuepicker form
* @internal
*/
#[Autoconfigure(public: true)]
final readonly class ValuePickerItemDataProvider implements FormDataProviderInterface
{
public function __construct(
private SourceHostProvider $sourceHostProvider,
) {}
/**
* Add sys_domains into $result data array
*
* @param array $result Initialized result array
* @return array Result filled with more data
*/
public function addData(array $result): array
{
if ($result['tableName'] === 'sys_redirect' && isset($result['processedTca']['columns']['source_host'])) {
// Don't add the wildcard domain for the qrcode / short_url types,
// because this case does not exist in context of a qrcode / short_url.
$redirectType = $result['databaseRow']['redirect_type'] ?? '';
if ($redirectType !== Demand::QRCODE_REDIRECT_TYPE && $redirectType !== Demand::SHORT_URL_REDIRECT_TYPE) {
$result['processedTca']['columns']['source_host']['config']['valuePicker']['items'][] = [
'label' => '*',
'value' => '*',
];
}
$domains = $this->sourceHostProvider->getHosts();
foreach ($domains as $domain) {
$result['processedTca']['columns']['source_host']['config']['valuePicker']['items'][]
= [
'label' => $domain,
'value' => $domain,
];
}
}
return $result;
}
}
@@ -0,0 +1,92 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Redirects\Hooks;
use TYPO3\CMS\Core\Database\Connection;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\DataHandling\DataHandler;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Redirects\Service\RedirectCacheService;
/**
* Ensure to clear the cache entry when a sys_redirect record is modified or deleted
* @internal This class is a specific TYPO3 hook implementation and is not part of the Public TYPO3 API.
*/
class DataHandlerCacheFlushingHook
{
/**
* Check if the data handler processed a sys_redirect record, if so, rebuild the redirect index cache
*
* @todo This hook is called for each record which needs to clear cache, which means this gets called
* for other records than sys_redirects, but also for each sys_redirect record which has been
* modified with this DataHandler call. Even if we can narrow down to rebuild only for specific
* source_hosts, this still means that we eventually rebuild the "same" cache multiple times.
* Find a better way to aggregate them and rebuild only once at the end.
*/
public function rebuildRedirectCacheIfNecessary(array $parameters, DataHandler $dataHandler): void
{
if (
($parameters['table'] ?? false) !== 'sys_redirect'
|| !($parameters['uid'] ?? false)
|| (
!isset($dataHandler->datamap['sys_redirect'])
&& !isset($dataHandler->cmdmap['sys_redirect'][(int)$parameters['uid']])
)
) {
return;
}
$redirectCacheService = GeneralUtility::makeInstance(RedirectCacheService::class);
$sourceHosts = [];
if (isset($dataHandler->getHistoryRecords()['sys_redirect:' . (int)$parameters['uid']]['oldRecord']['source_host'])) {
$sourceHosts[] = $dataHandler->getHistoryRecords()['sys_redirect:' . (int)$parameters['uid']]['oldRecord']['source_host'];
}
if (isset($dataHandler->getHistoryRecords()['sys_redirect:' . (int)$parameters['uid']]['newRecord']['source_host'])) {
$sourceHosts[] = $dataHandler->getHistoryRecords()['sys_redirect:' . (int)$parameters['uid']]['newRecord']['source_host'];
}
// only do record lookup for delete cmd, otherwise we cannot get old and new source_host,
// thus rebuildAll() should be executed as a safety net anyway.
if ($sourceHosts === [] && isset($dataHandler->cmdmap['sys_redirect'][(int)$parameters['uid']])) {
$queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('sys_redirect');
$queryBuilder->getRestrictions()->removeAll();
$row = $queryBuilder
->select('source_host')
->from('sys_redirect')
->where(
$queryBuilder->expr()->eq('uid', $queryBuilder->createNamedParameter($parameters['uid'], Connection::PARAM_INT))
)
->executeQuery()
->fetchAssociative();
if (isset($row['source_host'])) {
$sourceHosts[] = $row['source_host'] ?: '*';
}
}
// rebuild only specific source_host redirect caches
if ($sourceHosts !== []) {
foreach (array_unique($sourceHosts) as $sourceHost) {
$redirectCacheService->rebuildForHost($sourceHost);
}
return;
}
// Hopefully we get distinct source_host before. However, rebuild all redirect caches as a safety fallback.
$redirectCacheService->rebuildAll();
}
}
@@ -0,0 +1,77 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Redirects\Hooks;
use Symfony\Component\DependencyInjection\Attribute\Autoconfigure;
use TYPO3\CMS\Core\DataHandling\DataHandler;
use TYPO3\CMS\Core\SysLog\Action\Database as SystemLogDatabaseAction;
use TYPO3\CMS\Core\SysLog\Error as SystemLogErrorClassification;
use TYPO3\CMS\Core\Utility\MathUtility;
use TYPO3\CMS\Redirects\Security\RedirectPermissionGuard;
/**
* @internal This class is a specific TYPO3 hook implementation and is not part of the Public TYPO3 API.
*/
#[Autoconfigure(public: true)]
final readonly class DataHandlerPermissionGuardHook
{
public function __construct(
private RedirectPermissionGuard $redirectPermissionGuard,
) {}
/**
* @param array<string, mixed>|null $incomingFieldArray
* @param-out array<string, mixed>|null $incomingFieldArray
*/
public function processDatamap_preProcessFieldArray(
?array &$incomingFieldArray,
string $table,
string|int $id,
DataHandler $dataHandler,
): void {
if ($table === 'sys_redirect' && !$this->redirectPermissionGuard->isAllowedRedirect($incomingFieldArray ?? [])) {
// Reset incoming field array to avoid further processing in DataHandler
// in case the given source host is not allowed for the current user
$incomingFieldArray = null;
if (MathUtility::canBeInterpretedAsInteger($id)) {
// Record update
$dataHandler->log(
'sys_redirect',
(int)$id,
SystemLogDatabaseAction::UPDATE,
null,
SystemLogErrorClassification::USER_ERROR,
'Attempt to modify sys_redirect record "%d" is disallowed',
null,
[$id],
);
} else {
// New record
$dataHandler->log(
'sys_redirect',
0,
SystemLogDatabaseAction::INSERT,
null,
SystemLogErrorClassification::USER_ERROR,
'Attempt to create a new sys_redirect record is disallowed',
);
}
}
}
}
+109
View File
@@ -0,0 +1,109 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Redirects\Hooks;
use Symfony\Component\DependencyInjection\Attribute\Autoconfigure;
use TYPO3\CMS\Backend\Utility\BackendUtility;
use TYPO3\CMS\Core\DataHandling\DataHandler;
use TYPO3\CMS\Core\Utility\MathUtility;
use TYPO3\CMS\Redirects\RedirectUpdate\SlugRedirectChangeItem;
use TYPO3\CMS\Redirects\RedirectUpdate\SlugRedirectChangeItemFactory;
use TYPO3\CMS\Redirects\Service\SlugService;
/**
* @internal This class is a specific TYPO3 hook implementation and is not part of the Public TYPO3 API.
*/
#[Autoconfigure(public: true)]
class DataHandlerSlugUpdateHook
{
/**
* Persisted slug values per record UID
* e.g. `[13 => SlugRedirectChangeItem( $original = ['slug' => 'slug-a'] ), 14 => SlugRedirectChangeItem( $original = ['slug' => 'slug-x/example'] )`
*
* @var array<int, SlugRedirectChangeItem>
*/
protected $persistedChangedItems;
public function __construct(
protected SlugService $slugService,
protected SlugRedirectChangeItemFactory $slugRedirectChangeItemFactory,
) {}
/**
* Sets the current user id for new records in the "createdby" field.
* Collects slugs of persisted records before having been updated.
*
* @param string|int $id (id could be string, for this reason no type hint)
*/
public function processDatamap_preProcessFieldArray(array &$incomingFieldArray, string $table, $id, DataHandler $dataHandler): void
{
if ($table === 'sys_redirect' && !MathUtility::canBeInterpretedAsInteger($id)) {
$incomingFieldArray['createdby'] = $dataHandler->BE_USER->user['uid'];
return;
}
if ($table !== 'pages'
|| empty($incomingFieldArray['slug'])
|| $this->isNestedHookInvocation($dataHandler)
|| !MathUtility::canBeInterpretedAsInteger($id)
|| !$dataHandler->hasPermissionToUpdate('pages', BackendUtility::getRecord('pages', (int)$id) ?? [])
) {
return;
}
$changeItem = $this->slugRedirectChangeItemFactory->create((int)$id);
if ($changeItem === null) {
return;
}
$this->persistedChangedItems[(int)$id] = $changeItem;
}
/**
* Acts on potential slug changes.
*
* Hook `processDatamap_afterDatabaseOperations` is a record has been persisted and after `DataHandler::fillInFields`
* which ensure access to `pages.slug` field and applies possible evaluations (`eval => 'trim,...`).
*/
public function processDatamap_afterDatabaseOperations(string $status, string $table, $id, array $fieldArray, DataHandler $dataHandler): void
{
$persistedChangedItem = $this->persistedChangedItems[(int)$id] ?? null;
if (
$persistedChangedItem === null
|| $table !== 'pages'
|| $status !== 'update'
|| empty($fieldArray['slug'])
|| $persistedChangedItem->getOriginal()['slug'] === $fieldArray['slug']
|| $this->isNestedHookInvocation($dataHandler)
) {
return;
}
// We merge the fieldArray dataset into with the original record to spare a database query here.
$persistedChangedItem = $persistedChangedItem->withChanged(array_merge($persistedChangedItem->getOriginal(), $fieldArray));
$this->slugService->rebuildSlugsForSlugChange($id, $persistedChangedItem, $dataHandler->getCorrelationId());
}
/**
* Determines whether our identifier is part of correlation id aspects.
* In that case it would be a nested call which has to be ignored.
*/
protected function isNestedHookInvocation(DataHandler $dataHandler): bool
{
$correlationId = $dataHandler->getCorrelationId();
$correlationIdAspects = $correlationId ? $correlationId->getAspects() : [];
return in_array(SlugService::CORRELATION_ID_IDENTIFIER, $correlationIdAspects, true);
}
}
@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Redirects\Hooks;
use TYPO3\CMS\Core\Page\JavaScriptModuleInstruction;
use TYPO3\CMS\Core\Page\PageRenderer;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* @internal
*/
final class DispatchNotificationHook
{
/**
* Called as a hook in \TYPO3\CMS\Backend\Utility\BackendUtility::getUpdateSignalDetails
* calls a JS function to send the slug change notification
*
* @param array $params
*/
public function dispatchNotification(&$params)
{
$javaScriptRenderer = GeneralUtility::makeInstance(PageRenderer::class)->getJavaScriptRenderer();
$javaScriptRenderer->addJavaScriptModuleInstruction(
// @todo refactor to directly invoke the redirects slugChanged() method
// instead of dispatching an event that is only catched by the event dispatcher itself
JavaScriptModuleInstruction::create('@typo3/redirects/event-handler.js')
->addFlags(JavaScriptModuleInstruction::FLAG_USE_TOP_WINDOW)
->invoke('dispatchCustomEvent', 'typo3:redirects:slugChanged', $params['parameter'])
);
// not modifying `$params`, since instruction is added to global `PageRenderer`
}
}
+51
View File
@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Redirects\Hooks;
use Symfony\Component\DependencyInjection\Attribute\Autoconfigure;
use TYPO3\CMS\Core\DataHandling\DataHandler;
use TYPO3\CMS\Core\Utility\MathUtility;
use TYPO3\CMS\Core\Utility\StringUtility;
/**
* Initially set values for sys_redirects of type "qrcode"
*
* @internal
*/
#[Autoconfigure(public: true)]
final class HandleNewQrCodeRecord
{
public function processDatamap_preProcessFieldArray(&$incomingFieldArray, $table, $id, DataHandler $dataHandler): void
{
if ($table !== 'sys_redirect') {
return;
}
if (isset($incomingFieldArray['redirect_type'])
&& $incomingFieldArray['redirect_type'] === 'qrcode'
&& !isset($incomingFieldArray['source_path'])
&& !MathUtility::canBeInterpretedAsInteger($id)
) {
$incomingFieldArray['source_path'] = StringUtility::getUniqueId('/_redirect/');
$incomingFieldArray['keep_query_parameters'] = 1;
$incomingFieldArray['protected'] = 1;
$incomingFieldArray['is_regexp'] = 0;
$incomingFieldArray['disabled'] = 0;
}
}
}
+86
View File
@@ -0,0 +1,86 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Redirects\Hooks;
use Symfony\Component\DependencyInjection\Attribute\Autoconfigure;
use TYPO3\CMS\Core\DataHandling\DataHandler;
use TYPO3\CMS\Core\Localization\LanguageService;
use TYPO3\CMS\Core\Messaging\FlashMessage;
use TYPO3\CMS\Core\Messaging\FlashMessageService;
use TYPO3\CMS\Core\Type\ContextualFeedbackSeverity;
use TYPO3\CMS\Core\Utility\MathUtility;
use TYPO3\CMS\Redirects\Service\ShortUrlService;
/**
* Initially set values for sys_redirects of type "short_url"
*
* @internal
*/
#[Autoconfigure(public: true)]
final readonly class HandleNewShortUrlRecord
{
public function __construct(
private ShortUrlService $shortUrlService,
private FlashMessageService $flashMessageService,
) {}
public function processDatamap_preProcessFieldArray(
?array &$incomingFieldArray,
string $table,
int|string $id,
DataHandler $dataHandler
): void {
if ($table !== 'sys_redirect'
|| MathUtility::canBeInterpretedAsInteger($id)
|| ($incomingFieldArray['redirect_type'] ?? '') !== 'short_url'
) {
return;
}
// Set defaults when creating a new Short URL
$incomingFieldArray['keep_query_parameters'] = 1;
$incomingFieldArray['protected'] = 1;
$incomingFieldArray['is_regexp'] = 0;
$incomingFieldArray['disabled'] = 0;
// Prevent saving the record if source_path is empty
if (empty($incomingFieldArray['source_path'])) {
$incomingFieldArray = null;
return;
}
// Add '/' at the beginning of the source_path if not present
$incomingFieldArray['source_path'] = $incomingFieldArray['source_path'][0] === '/' ? $incomingFieldArray['source_path'] : '/' . $incomingFieldArray['source_path'];
// Check that the short URL does not already exist
if (!$this->shortUrlService->isUniqueShortUrl($incomingFieldArray['source_host'], $incomingFieldArray['source_path'])) {
$incomingFieldArray = null;
$message = $this->getLanguageService()->sL('redirects.modules.short_urls:validation.duplicate_short_url');
$flashMessage = new FlashMessage(
$message,
'',
ContextualFeedbackSeverity::ERROR,
true
);
$defaultFlashMessageQueue = $this->flashMessageService->getMessageQueueByIdentifier();
$defaultFlashMessageQueue->enqueue($flashMessage);
}
}
private function getLanguageService(): LanguageService
{
return $GLOBALS['LANG'];
}
}
+180
View File
@@ -0,0 +1,180 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license 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\Http\Middleware;
use Psr\EventDispatcher\EventDispatcherInterface;
use Psr\Http\Message\ResponseFactoryInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\UriInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Psr\Log\LoggerInterface;
use TYPO3\CMS\Core\Utility\HttpUtility;
use TYPO3\CMS\Redirects\Event\RedirectWasHitEvent;
use TYPO3\CMS\Redirects\Service\RedirectService;
/**
* Hooks into the frontend request, and checks if a redirect should apply,
* If so, a redirect response is triggered.
*
* @internal
*/
class RedirectHandler implements MiddlewareInterface
{
protected RedirectService $redirectService;
protected EventDispatcherInterface $eventDispatcher;
protected ResponseFactoryInterface $responseFactory;
protected LoggerInterface $logger;
public function __construct(
RedirectService $redirectService,
EventDispatcherInterface $eventDispatcher,
ResponseFactoryInterface $responseFactory,
LoggerInterface $logger
) {
$this->redirectService = $redirectService;
$this->eventDispatcher = $eventDispatcher;
$this->responseFactory = $responseFactory;
$this->logger = $logger;
}
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
$port = $request->getUri()->getPort();
$matchedRedirect = $this->redirectService->matchRedirect(
$request->getUri()->getHost() . ($port ? ':' . $port : ''),
$request->getUri()->getPath(),
$request->getUri()->getQuery()
);
// If the matched redirect is found, resolve it, and check further
if (!is_array($matchedRedirect)) {
return $handler->handle($request);
}
$url = $this->redirectService->getTargetUrl($matchedRedirect, $request);
if ($url === null) {
return $handler->handle($request);
}
if ($this->redirectUriWillRedirectToCurrentUri($request, $url)) {
if ($this->isEmptyRedirectUri($url)) {
// Empty uri leads to a redirect loop in Firefox, whereas Chrome would stop it but not displaying anything.
// @see https://forge.typo3.org/issues/100791
$this->logger->error('Empty redirect points to itself! Aborting.', ['record' => $matchedRedirect, 'uri' => (string)$url]);
} elseif ($url->getFragment()) {
// Enrich error message for unsharp check with target url fragment.
$this->logger->error('Redirect ' . $url->getPath() . ' eventually points to itself! Target with fragment can not be checked and we take the safe check to avoid redirect loops. Aborting.', ['record' => $matchedRedirect, 'uri' => (string)$url]);
} else {
$this->logger->error('Redirect ' . $url->getPath() . ' points to itself! Aborting.', ['record' => $matchedRedirect, 'uri' => (string)$url]);
}
return $handler->handle($request);
}
$this->logger->debug('Redirecting', ['record' => $matchedRedirect, 'uri' => (string)$url]);
$response = $this->buildRedirectResponse($url, $matchedRedirect);
// Dispatch event, allowing listeners to execute further tasks and to adjust the PSR-7 response
return $this->eventDispatcher->dispatch(
new RedirectWasHitEvent($request, $response, $matchedRedirect, $url)
)->getResponse();
}
protected function buildRedirectResponse(UriInterface $uri, array $redirectRecord): ResponseInterface
{
return $this->responseFactory
->createResponse((int)$redirectRecord['target_statuscode'])
->withHeader('location', (string)$uri)
->withHeader('X-Redirect-By', 'TYPO3 Redirect ' . $redirectRecord['uid']);
}
/**
* Checks if redirect uri matches current request uri.
*/
protected function redirectUriWillRedirectToCurrentUri(ServerRequestInterface $request, UriInterface $redirectUri): bool
{
if ($this->isEmptyRedirectUri($redirectUri)) {
return true;
}
$requestUri = $request->getUri();
$redirectIsAbsolute = $redirectUri->getHost() && $redirectUri->getScheme();
$requestUri = $this->sanitizeUriForComparison($requestUri, !$redirectIsAbsolute);
$redirectUri = $this->sanitizeUriForComparison($redirectUri, !$redirectIsAbsolute);
return (string)$requestUri === (string)$redirectUri;
}
/**
* Strip down uri to be suitable to make valid comparison in 'redirectUriWillRedirectToCurrentUri()'
* if uri is pointing to itself and redirect should be processed.
*/
protected function sanitizeUriForComparison(UriInterface $uri, bool $relativeCheck): UriInterface
{
// Remove schema, host and port if we need to sanitize for relative check.
if ($relativeCheck) {
$uri = $uri->withScheme('')->withHost('')->withPort(null);
}
// Remove default port by schema, as they are superfluous and not meaningful enough, and even not
// set in a request uri as this depends a lot on the used webserver setup and infrastructure.
$portDefaultSchemaMap = [
// we only need web ports here, as web request could not be done over another
// schema at all, ex. ftp or mailto.
80 => 'http',
443 => 'https',
];
if (
!$relativeCheck
&& $uri->getScheme()
&& isset($portDefaultSchemaMap[$uri->getPort() ?? ''])
&& $uri->getScheme() === $portDefaultSchemaMap[$uri->getPort()]
) {
$uri = $uri->withPort(null);
}
// Remove userinfo, as request would not hold it and so comparing would lead to a false-positive result
if ($uri->getUserInfo()) {
$uri = $uri->withUserInfo('');
}
// Browser should and do not hand over the fragment part in a request as this is defined to be handled
// by clients only in the protocol, thus we remove the fragment to be safe and do not end in redirect loop
// for targets with fragments because we do not get it in the request. Still not optimal but the best we
// can do in this case.
if ($uri->getFragment()) {
$uri = $uri->withFragment('');
}
// Query arguments do not have to be in the same order to be the same outcome, thus sorting them will
// give us a valid comparison, and we can correctly determine if we would have a redirect to the same uri.
// Arguments with empty values are kept, because removing them might lead to false-positives in some cases.
if ($uri->getQuery()) {
$parts = [];
parse_str($uri->getQuery(), $parts);
ksort($parts);
$uri = $uri->withQuery(HttpUtility::buildQueryString($parts));
}
return $uri;
}
/**
* Empty uri leads to a redirect loop in Firefox, whereas Chrome would stop it but not displaying anything.
* @see https://forge.typo3.org/issues/100791
*/
private function isEmptyRedirectUri(UriInterface $uri): bool
{
return (string)$uri === '';
}
}
+57
View File
@@ -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\Message;
use Psr\Http\Message\UriInterface;
use TYPO3\CMS\Core\Attribute\WebhookMessage;
use TYPO3\CMS\Core\Messaging\WebhookMessageInterface;
use TYPO3\CMS\Redirects\Event\RedirectWasHitEvent;
#[WebhookMessage(
identifier: 'typo3/redirect-was-hit',
description: 'LLL:EXT:redirects/Resources/Private/Language/locallang_db.xlf:sys_redirect.webhook_type.typo3-redirect-was-hit'
)]
final class RedirectWasHitMessage implements WebhookMessageInterface
{
public function __construct(
private readonly UriInterface $sourceUrl,
private readonly UriInterface $targetUrl,
private readonly int $statusCode,
private readonly array $matchedRedirect,
) {}
public static function createFromEvent(RedirectWasHitEvent $event): self
{
return new self(
$event->getRequest()->getUri(),
$event->getTargetUrl(),
$event->getResponse()->getStatusCode(),
$event->getMatchedRedirect(),
);
}
public function jsonSerialize(): array
{
return [
'sourceUrl' => (string)$this->sourceUrl,
'targetUrl' => (string)$this->targetUrl,
'statusCode' => $this->statusCode,
'redirect' => $this->matchedRedirect,
];
}
}
+48
View File
@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Redirects\RedirectUpdate;
final readonly class PageTypeSource implements RedirectSourceInterface
{
public function __construct(
private string $host,
private string $path,
private int $pageType,
private array $targetLinkParameters,
) {}
public function getHost(): string
{
return $this->host;
}
public function getPath(): string
{
return $this->path;
}
public function getPageType(): int
{
return $this->pageType;
}
public function getTargetLinkParameters(): array
{
return $this->targetLinkParameters;
}
}
@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Redirects\RedirectUpdate;
/**
* @internal This is a concrete implementation and solely used for EXT:redirects and not part of TYPO3's Core API. It
* may vanish any time.
*/
final readonly class PlainSlugReplacementRedirectSource implements RedirectSourceInterface
{
public function __construct(
private string $host,
private string $path,
private array $targetLinkParameters,
) {}
public function getHost(): string
{
return $this->host;
}
public function getPath(): string
{
return $this->path;
}
public function getTargetLinkParameters(): array
{
return $this->targetLinkParameters;
}
}
@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Redirects\RedirectUpdate;
/**
* @internal This is a concrete collection implementation and solely used for EXT:redirects and not part of TYPO3's Core API.
*/
final class RedirectSourceCollection implements \Countable
{
/**
* @var list<RedirectSourceInterface>
*/
private array $sources;
private int $count;
public function __construct(RedirectSourceInterface ...$sources)
{
// Ensure to strip out eventually containing associative keys
$this->sources = array_values($sources);
$this->count = count($this->sources);
}
/**
* @return list<RedirectSourceInterface>
*/
public function all(): array
{
return $this->sources;
}
public function count(): int
{
return $this->count;
}
}
@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Redirects\RedirectUpdate;
/**
* This is a contract containing the base information on an original redirect that should
* be transformed or replaced.
*/
interface RedirectSourceInterface
{
public function getHost(): string;
public function getPath(): string;
public function getTargetLinkParameters(): array;
}
@@ -0,0 +1,101 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Redirects\RedirectUpdate;
use TYPO3\CMS\Core\Site\Entity\Site;
use TYPO3\CMS\Core\Site\Entity\SiteLanguage;
/**
* This class contains data on the journey through the auto-create-redirects
* path. It may be also included as information in a PSR-14 event, if needed.
*
* @internal This class is a specific data container for slug service handling and is not part of the public TYPO3 API.
*/
final readonly class SlugRedirectChangeItem
{
public function __construct(
private int $defaultLanguagePageId,
private int $pageId,
private Site $site,
private SiteLanguage $siteLanguage,
private array $original,
private RedirectSourceCollection $sourcesCollection,
private ?array $changed = null,
) {}
public function getDefaultLanguagePageId(): int
{
return $this->defaultLanguagePageId;
}
public function getPageId(): int
{
return $this->pageId;
}
public function getOriginal(): array
{
return $this->original;
}
public function getChanged(): ?array
{
return $this->changed;
}
public function getSite(): Site
{
return $this->site;
}
public function getSiteLanguage(): SiteLanguage
{
return $this->siteLanguage;
}
public function getSourcesCollection(): RedirectSourceCollection
{
return $this->sourcesCollection;
}
public function withChanged(array $changed): self
{
return new self(
defaultLanguagePageId: $this->defaultLanguagePageId,
pageId: $this->pageId,
site: $this->site,
siteLanguage: $this->siteLanguage,
original: $this->original,
sourcesCollection: $this->sourcesCollection,
changed: $changed,
);
}
public function withSourcesCollection(RedirectSourceCollection $sourcesCollection): self
{
return new self(
defaultLanguagePageId: $this->defaultLanguagePageId,
pageId: $this->pageId,
site: $this->site,
siteLanguage: $this->siteLanguage,
original: $this->original,
sourcesCollection: $sourcesCollection,
changed: $this->changed,
);
}
}
@@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Redirects\RedirectUpdate;
use Psr\EventDispatcher\EventDispatcherInterface;
use TYPO3\CMS\Backend\Utility\BackendUtility;
use TYPO3\CMS\Core\Exception\SiteNotFoundException;
use TYPO3\CMS\Core\Site\SiteFinder;
use TYPO3\CMS\Redirects\Event\SlugRedirectChangeItemCreatedEvent;
/**
* @internal This factory class is a specific implementation for creating SlugRedirectChangeItems
* and is not part of the public TYPO3 API.
*/
final readonly class SlugRedirectChangeItemFactory
{
public function __construct(
private SiteFinder $siteFinder,
private EventDispatcherInterface $eventDispatcher,
) {}
public function create(int $pageId, ?array $original = null, ?array $changed = null): ?SlugRedirectChangeItem
{
// @todo Consider to omit hidden or scheduled records completly, eventually based on configuration.
$original ??= BackendUtility::getRecordWSOL('pages', $pageId);
if (!$original) {
return null;
}
$languageId = (int)$original['language_tag'];
$defaultLanguagePageId = (int)$original['language_tag'] > 0 ? (int)$original['l10n_parent'] : $pageId;
try {
$site = $this->siteFinder->getSiteByPageId($defaultLanguagePageId);
} catch (SiteNotFoundException) {
// "autoCreateRedirects" and "autoUpdateSlugs" are site configuration settings. Not finding one
// means that we should not handle the creation of them, thus no need to create a change item.
return null;
}
$siteLanguage = $site->getLanguageById($languageId);
// Verify we should process auto redirect creation or slug updating. If not return early avoiding to create
// a change item which is superflous at all.
$settings = $site->getSettings();
$autoUpdateSlugs = (bool)$settings->get('redirects.autoUpdateSlugs', true);
$autoCreateRedirects = (bool)$settings->get('redirects.autoCreateRedirects', true);
if (!($autoUpdateSlugs || $autoCreateRedirects)) {
return null;
}
$changeItem = new SlugRedirectChangeItem(
defaultLanguagePageId: $defaultLanguagePageId,
pageId: $pageId,
site: $site,
siteLanguage: $siteLanguage,
original: $original,
sourcesCollection: new RedirectSourceCollection(),
changed: $changed
);
return $this->eventDispatcher->dispatch(new SlugRedirectChangeItemCreatedEvent($changeItem))
->getSlugRedirectChangeItem();
}
}
+160
View File
@@ -0,0 +1,160 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Redirects\Report\Status;
use Psr\Http\Message\ServerRequestInterface;
use Symfony\Component\DependencyInjection\Attribute\Autoconfigure;
use TYPO3\CMS\Backend\View\BackendViewFactory;
use TYPO3\CMS\Core\Localization\LanguageService;
use TYPO3\CMS\Core\Registry;
use TYPO3\CMS\Core\Type\ContextualFeedbackSeverity;
use TYPO3\CMS\Redirects\Command\CheckIntegrityCommand;
use TYPO3\CMS\Redirects\Configuration\CheckIntegrityConfiguration;
use TYPO3\CMS\Redirects\Repository\RedirectRepository;
use TYPO3\CMS\Reports\RequestAwareStatusProviderInterface;
use TYPO3\CMS\Reports\Status;
use TYPO3\CMS\Reports\StatusProviderInterface;
/**
* Performs checks regarding redirects
*/
#[Autoconfigure(public: true)]
readonly class RedirectStatus implements StatusProviderInterface, RequestAwareStatusProviderInterface
{
public function __construct(
protected BackendViewFactory $backendViewFactory,
protected RedirectRepository $redirectRepository,
protected Registry $registry,
protected CheckIntegrityConfiguration $checkIntegrityConfiguration,
) {}
/**
* Determines the status of redirect conflicts and shows an info status in some cases
* in case redirects:checkintegrity was not run lately.
*
* OK if
* no redirects
* OR
* no redirect conflicts
* WARNING if
* conflicts found
*
* Additionally:
* INFO if
* checkintegrity was not run and redirects exist
*
* @return Status[]
*/
public function getStatus(?ServerRequestInterface $request = null): array
{
if ($request === null) {
return [];
}
if ($this->redirectRepository->countActiveRedirects() === 0) {
return ['Conflicts' => $this->getNoRedirectsStatus($request)];
}
$statusArray = ['Conflicts' => $this->getConflictingRedirectsStatus($request)];
$lastCheckIntegrityStatus = $this->getLastCheckIntegrityStatus();
if ($lastCheckIntegrityStatus !== null) {
$statusArray['Last checkintegrity'] = $lastCheckIntegrityStatus;
}
return $statusArray;
}
public function getLabel(): string
{
return 'LLL:EXT:redirects/Resources/Private/Language/locallang_reports.xlf:statusProvider';
}
protected function getNoRedirectsStatus(ServerRequestInterface $request): Status
{
$view = $this->backendViewFactory->create($request, ['typo3/cms-redirects']);
$view->assignMultiple([
'count' => 0,
'reportedConflicts' => [],
]);
return new Status(
$this->getLanguageService()->sL('LLL:EXT:redirects/Resources/Private/Language/locallang_reports.xlf:status.conflictingRedirects'),
$this->getLanguageService()->sL('LLL:EXT:redirects/Resources/Private/Language/locallang_reports.xlf:status.conflictingRedirects.none'),
$view->render('Report/RedirectStatus'),
ContextualFeedbackSeverity::OK
);
}
protected function getConflictingRedirectsStatus(ServerRequestInterface $request): Status
{
$value = $this->getLanguageService()->sL('LLL:EXT:redirects/Resources/Private/Language/locallang_reports.xlf:status.conflictingRedirects.none');
$severity = ContextualFeedbackSeverity::OK;
$reportedConflicts = $this->getConflictingRedirects();
$count = count($reportedConflicts);
if ($count > 0) {
$value = sprintf($this->getLanguageService()->sL('LLL:EXT:redirects/Resources/Private/Language/locallang_reports.xlf:status.conflictingRedirects.count'), $count);
$severity = ContextualFeedbackSeverity::WARNING;
}
$view = $this->backendViewFactory->create($request, ['typo3/cms-redirects']);
$view->assignMultiple([
'count' => $count,
'reportedConflicts' => $reportedConflicts,
]);
return new Status(
$this->getLanguageService()->sL('LLL:EXT:redirects/Resources/Private/Language/locallang_reports.xlf:status.conflictingRedirects'),
$value,
$view->render('Report/RedirectStatus'),
$severity
);
}
protected function getLastCheckIntegrityStatus(): ?Status
{
if (!$this->checkIntegrityConfiguration->showInfoInReports) {
return null;
}
$lastCheck = $this->getCheckIntegrityLastCheckTimestamp();
$hasCheckedBefore = $lastCheck > 0;
$checkPoint = time() - $this->checkIntegrityConfiguration->seconds;
$lastCheckIsWithinCheckPeriod = $lastCheck >= $checkPoint;
if (!$hasCheckedBefore || !$lastCheckIsWithinCheckPeriod) {
return new Status(
$this->getLanguageService()->sL('LLL:EXT:redirects/Resources/Private/Language/locallang_reports.xlf:status.checkIntegrityResultState'),
$this->getLanguageService()->sL('LLL:EXT:redirects/Resources/Private/Language/locallang_reports.xlf:status.checkIntegrityResultState.title'),
$this->getLanguageService()->sL('LLL:EXT:redirects/Resources/Private/Language/locallang_reports.xlf:status.checkIntegrityResultState.message'),
ContextualFeedbackSeverity::INFO
);
}
return null;
}
protected function getConflictingRedirects(): array
{
return $this->registry->get('tx_redirects', CheckIntegrityCommand::REGISTRY_KEY_CONFLICTING_REDIRECTS, []);
}
protected function getCheckIntegrityLastCheckTimestamp(): int
{
return $this->registry->get('tx_redirects', CheckIntegrityCommand::REGISTRY_KEY_LAST_TIMESTAMP_CHECK_INTEGRITY, 0);
}
protected function getLanguageService(): LanguageService
{
return $GLOBALS['LANG'];
}
}
+353
View File
@@ -0,0 +1,353 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license 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\Repository;
use Psr\Http\Message\ServerRequestInterface;
use Symfony\Component\Console\Input\InputInterface;
/**
* Demand Object for filtering redirects in the backend module
* @internal
*/
class Demand
{
public const DEFAULT_REDIRECT_TYPE = 'default';
public const QRCODE_REDIRECT_TYPE = 'qrcode';
public const SHORT_URL_REDIRECT_TYPE = 'short_url';
protected const ORDER_DESCENDING = 'desc';
protected const ORDER_ASCENDING = 'asc';
protected const DEFAULT_ORDER_FIELD = 'source_host';
protected const DEFAULT_SECONDARY_ORDER_FIELD = 'source_host';
protected const ORDER_FIELDS = ['source_host', 'source_path', 'lasthiton', 'hitcount', 'protected'];
protected string $orderField;
protected string $orderDirection;
protected string $redirectType;
/**
* @var string[]
*/
protected array $sourceHosts;
protected string $sourcePath;
protected string $target;
/**
* @var int[]
*/
protected array $statusCodes = [];
protected int $limit = 50;
protected int $page;
protected string $secondaryOrderField;
private int $maxHits;
private ?\DateTimeInterface $olderThan;
protected ?int $creationType = -1;
protected ?int $protected = -1;
protected ?string $integrityStatus = null;
public function __construct(
int $page = 1,
string $orderField = self::DEFAULT_ORDER_FIELD,
string $orderDirection = self::ORDER_ASCENDING,
string $redirectType = self::DEFAULT_REDIRECT_TYPE,
array $sourceHosts = [],
string $sourcePath = '',
string $target = '',
array $statusCodes = [],
int $maxHits = 0,
?\DateTimeInterface $olderThan = null,
?int $creationType = -1,
?int $protected = -1,
?string $integrityStatus = null
) {
$this->page = $page;
if (!in_array($orderField, self::ORDER_FIELDS, true)) {
$orderField = self::DEFAULT_ORDER_FIELD;
}
$this->orderField = $orderField;
if (!in_array($orderDirection, [self::ORDER_DESCENDING, self::ORDER_ASCENDING], true)) {
$orderDirection = self::ORDER_ASCENDING;
}
$this->orderDirection = $orderDirection;
$this->redirectType = $redirectType;
$this->sourceHosts = $sourceHosts;
$this->sourcePath = $sourcePath;
$this->target = $target;
$this->statusCodes = $statusCodes;
$this->secondaryOrderField = $this->orderField === self::DEFAULT_ORDER_FIELD ? self::DEFAULT_SECONDARY_ORDER_FIELD : '';
$this->maxHits = $maxHits;
$this->olderThan = $olderThan;
$this->creationType = $creationType;
$this->protected = $protected;
$this->integrityStatus = $integrityStatus;
}
public static function fromRequest(ServerRequestInterface $request): self
{
$page = (int)($request->getQueryParams()['page'] ?? $request->getParsedBody()['page'] ?? 1);
$orderField = $request->getQueryParams()['orderField'] ?? $request->getParsedBody()['orderField'] ?? self::DEFAULT_ORDER_FIELD;
$orderDirection = $request->getQueryParams()['orderDirection'] ?? $request->getParsedBody()['orderDirection'] ?? self::ORDER_ASCENDING;
$redirectType = (string)($request->getAttribute('moduleData')->get('redirectType') ?? self::DEFAULT_REDIRECT_TYPE);
$demand = $request->getQueryParams()['demand'] ?? $request->getParsedBody()['demand'] ?? [];
if (empty($demand)) {
return new self($page, $orderField, $orderDirection, $redirectType);
}
$sourceHost = $demand['source_host'] ?? '';
$sourceHosts = $sourceHost ? [$sourceHost] : [];
$sourcePath = $demand['source_path'] ?? '';
$statusCode = (int)($demand['target_statuscode'] ?? 0);
$statusCodes = $statusCode > 0 ? [$statusCode] : [];
$target = $demand['target'] ?? '';
$maxHits = (int)($demand['max_hits'] ?? 0);
$creationType = isset($demand['creation_type']) ? ((int)$demand['creation_type']) : -1;
$protected = isset($demand['protected']) ? ((int)$demand['protected']) : -1;
$integrityStatus = isset($demand['integrity_status']) ? ((string)$demand['integrity_status']) : null;
return new self($page, $orderField, $orderDirection, $redirectType, $sourceHosts, $sourcePath, $target, $statusCodes, $maxHits, null, $creationType, $protected, $integrityStatus);
}
public static function fromCommandInput(InputInterface $input): self
{
return new self(
1,
self::DEFAULT_ORDER_FIELD,
self::ORDER_ASCENDING,
(string)$input->getOption('redirectType'),
(array)$input->getOption('domain'),
(string)$input->getOption('path'),
'',
(array)$input->getOption('statusCode'),
$input->hasOption('hitCount') ? (int)$input->getOption('hitCount') : 0,
$input->getOption('days')
? new \DateTimeImmutable($input->getOption('days') . ' days ago')
: new \DateTimeImmutable('90 days ago'),
$input->hasOption('creationType') ? (int)($input->getOption('creationType')) : null,
$input->hasOption('protected') ? (int)($input->getOption('protected')) : null,
$input->hasOption('integrityStatus') ? (string)($input->getOption('integrityStatus')) : null
);
}
public function getMaxHits(): int
{
return $this->maxHits;
}
public function hasMaxHits(): bool
{
return $this->maxHits > 0;
}
public function getOlderThan(): ?\DateTimeInterface
{
return $this->olderThan;
}
public function hasOlderThan(): bool
{
return $this->olderThan instanceof \DateTimeInterface;
}
public function getOrderField(): string
{
return $this->orderField;
}
public function getOrderDirection(): string
{
return $this->orderDirection;
}
public function getRedirectType(): string
{
return $this->redirectType;
}
public function getDefaultOrderDirection(): string
{
return self::ORDER_ASCENDING;
}
public function getReverseOrderDirection(): string
{
return $this->orderDirection === self::ORDER_ASCENDING ? self::ORDER_DESCENDING : self::ORDER_ASCENDING;
}
public function hasSecondaryOrdering(): bool
{
return $this->secondaryOrderField !== '';
}
public function getSecondaryOrderField(): string
{
return $this->secondaryOrderField;
}
public function getFirstSourceHost(): string
{
return $this->sourceHosts[0] ?? '';
}
public function getSourceHosts(): ?array
{
return $this->sourceHosts === [] ? null : $this->sourceHosts;
}
public function getSourcePath(): string
{
return $this->sourcePath;
}
public function getTarget(): string
{
return $this->target;
}
public function getLimit(): int
{
return $this->limit;
}
public function getCreationType(): ?int
{
return $this->creationType;
}
public function getProtected(): ?int
{
return $this->protected;
}
public function getIntegrityStatus(): ?string
{
return $this->integrityStatus;
}
public function getFirstStatusCode(): int
{
return $this->statusCodes[0] ?? 0;
}
public function getStatusCodes(): array
{
return $this->statusCodes;
}
public function hasStatusCodes(): bool
{
return !empty($this->statusCodes);
}
public function hasSourceHosts(): bool
{
return !empty($this->sourceHosts);
}
public function hasSourcePath(): bool
{
return $this->sourcePath !== '';
}
public function hasTarget(): bool
{
return $this->target !== '';
}
public function hasCreationType(): bool
{
return $this->creationType !== null && $this->creationType !== -1;
}
public function hasProtected(): bool
{
return $this->protected !== null && $this->protected !== -1;
}
public function hasIntegrityStatus(): bool
{
return $this->integrityStatus !== null && $this->integrityStatus !== '';
}
public function hasRedirectType(): bool
{
return !empty($this->redirectType);
}
/**
* This is actually used for the backend filter and therefore only takes properties into account, which
* can be filtered for. For example "redirect_type" is not checked because it is not part of the filter.
*/
public function hasConstraints(): bool
{
return $this->hasSourcePath()
|| $this->hasSourceHosts()
|| $this->hasTarget()
|| $this->hasStatusCodes()
|| $this->hasMaxHits()
|| $this->hasCreationType()
|| $this->hasProtected()
|| $this->hasIntegrityStatus();
}
/**
* The current Page of the paginated redirects
*/
public function getPage(): int
{
return $this->page;
}
/**
* Offset for the current set of records
*/
public function getOffset(): int
{
return ($this->page - 1) * $this->limit;
}
public function getParameters(): array
{
$parameters = [];
if ($this->hasSourcePath()) {
$parameters['source_path'] = $this->getSourcePath();
}
if ($this->hasSourceHosts()) {
$parameters['source_host'] = $this->getFirstSourceHost();
}
if ($this->hasRedirectType()) {
$parameters['redirect_type'] = $this->getRedirectType();
}
if ($this->hasTarget()) {
$parameters['target'] = $this->getTarget();
}
if ($this->hasStatusCodes()) {
$parameters['target_statuscode'] = $this->getFirstStatusCode();
}
if ($this->hasMaxHits()) {
$parameters['max_hits'] = $this->getMaxHits();
}
if ($this->hasCreationType()) {
$parameters['creation_type'] = $this->getCreationType();
}
if ($this->hasProtected()) {
$parameters['protected'] = $this->getProtected();
}
if ($this->hasIntegrityStatus()) {
$parameters['integrity_status'] = $this->getIntegrityStatus();
}
return $parameters;
}
}
+496
View File
@@ -0,0 +1,496 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license 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\Repository;
use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
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\Schema\TcaSchema;
use TYPO3\CMS\Core\Schema\TcaSchemaFactory;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Redirects\Security\RedirectPermissionGuard;
/**
* Class for accessing redirect records from the database
* @internal
*/
class RedirectRepository
{
private TcaSchema $schema;
public function __construct(
TcaSchemaFactory $schemaFactory,
private readonly RedirectPermissionGuard $redirectPermissionGuard,
private readonly ConnectionPool $connectionPool,
) {
$this->schema = $schemaFactory->get('sys_redirect');
}
/**
* Used within the backend module, which also includes the hidden records, but never deleted records.
*/
public function findRedirectsByDemand(Demand $demand): array
{
// Fast path for admin users - use SQL pagination directly
if ($this->getBackendUser()->isAdmin()) {
return $this->getQueryBuilderForDemand($demand)
->select('*')
->setMaxResults($demand->getLimit())
->setFirstResult($demand->getOffset())
->executeQuery()
->fetchAllAssociative();
}
// Non-admin: Two-phase fetch without caching
// Phase 1: Fetch minimal fields for ALL matching records with SQL source host filtering
$queryBuilder = $this->getQueryBuilderForDemand($demand);
try {
$this->addSourceHostConstraint($queryBuilder);
} catch (StopQueryException) {
return [];
}
$redirects = $queryBuilder
->executeQuery()
->fetchAllAssociative();
// Phase 2: Apply PHP target permission filtering
$filteredRedirects = $this->sortOutInaccessibleRedirects($redirects);
// Phase 3: Get UIDs for current page (applying pagination in PHP)
$filteredUids = array_column($filteredRedirects, 'uid');
$currentPageUids = array_slice($filteredUids, $demand->getOffset(), $demand->getLimit());
if ($currentPageUids === []) {
return [];
}
// Phase 4: Fetch full records only for the current page
$queryBuilder = $this->getQueryBuilder();
return $queryBuilder
->select('*')
->from('sys_redirect')
->where(
$queryBuilder->expr()->in(
'uid',
$queryBuilder->createNamedParameter($currentPageUids, Connection::PARAM_INT_ARRAY)
)
)
->orderBy($demand->getOrderField(), $demand->getOrderDirection())
->executeQuery()
->fetchAllAssociative();
}
public function countRedirectsByDemand(Demand $demand): int
{
// Fast path for admin users - use SQL COUNT
if ($this->getBackendUser()->isAdmin()) {
$queryBuilder = $this->getQueryBuilderForDemand($demand, true);
return (int)$queryBuilder
->count('uid')
->executeQuery()
->fetchOne();
}
// Non-admin: Fetch minimal fields with SQL source host filtering
$queryBuilder = $this->getQueryBuilderForDemand($demand);
try {
$this->addSourceHostConstraint($queryBuilder);
} catch (StopQueryException) {
return 0;
}
$redirects = $queryBuilder
->executeQuery()
->fetchAllAssociative();
// Apply PHP target permission filtering and count
$filteredRedirects = $this->sortOutInaccessibleRedirects($redirects);
return count($filteredRedirects);
}
public function countActiveRedirects(): int
{
$queryBuilder = $this->connectionPool->getQueryBuilderForTable('sys_redirect');
return (int)$queryBuilder
->count('uid')
->from('sys_redirect')
->executeQuery()
->fetchOne();
}
/**
* Adds source host constraint to query for non-admin users
* This significantly reduces the dataset before PHP filtering
* @throws StopQueryException
*/
protected function addSourceHostConstraint(QueryBuilder $queryBuilder): void
{
// Admin users see all hosts
if ($this->getBackendUser()->isAdmin()) {
return;
}
// Get allowed hosts for the current user
$allowedHosts = $this->redirectPermissionGuard->getAllowedHosts();
if (empty($allowedHosts)) {
throw new StopQueryException('No allowed hosts found for current user', 1764702053);
}
$queryBuilder->andWhere(
$queryBuilder->expr()->in(
'source_host',
$queryBuilder->createNamedParameter($allowedHosts, Connection::PARAM_STR_ARRAY)
)
);
}
protected function getBackendUser(): BackendUserAuthentication
{
return $GLOBALS['BE_USER'];
}
/**
* Prepares the QueryBuilder with Constraints from the Demand
*/
protected function getQueryBuilderForDemand(Demand $demand, bool $createCountQuery = false): QueryBuilder
{
$queryBuilder = $this->getQueryBuilder();
if ($createCountQuery) {
$queryBuilder->count('uid');
} else {
$queryBuilder->select('uid', 'source_host', 'target');
}
$queryBuilder->from('sys_redirect');
if (!$createCountQuery) {
$queryBuilder->orderBy(
$demand->getOrderField(),
$demand->getOrderDirection()
);
if ($demand->hasSecondaryOrdering()) {
$queryBuilder->addOrderBy($demand->getSecondaryOrderField());
}
}
$constraints = [];
if ($demand->hasRedirectType()) {
$constraints[] = $queryBuilder->expr()->eq(
'redirect_type',
$queryBuilder->createNamedParameter($demand->getRedirectType())
);
}
if ($demand->hasSourceHosts()) {
$constraints[] = $queryBuilder->expr()->in(
'source_host',
$queryBuilder->createNamedParameter($demand->getSourceHosts(), Connection::PARAM_STR_ARRAY)
);
}
if ($demand->hasSourcePath()) {
$escapedLikeString = '%' . $queryBuilder->escapeLikeWildcards($demand->getSourcePath()) . '%';
$constraints[] = $queryBuilder->expr()->like(
'source_path',
$queryBuilder->createNamedParameter($escapedLikeString)
);
}
if ($demand->hasTarget()) {
$escapedLikeString = '%' . $queryBuilder->escapeLikeWildcards($demand->getTarget()) . '%';
$constraints[] = $queryBuilder->expr()->like(
'target',
$queryBuilder->createNamedParameter($escapedLikeString)
);
}
if ($demand->hasStatusCodes()) {
$constraints[] = $queryBuilder->expr()->in(
'target_statuscode',
$queryBuilder->createNamedParameter($demand->getStatusCodes(), Connection::PARAM_INT_ARRAY)
);
}
if ($demand->hasMaxHits()) {
$constraints[] = $queryBuilder->expr()->lt(
'hitcount',
$queryBuilder->createNamedParameter($demand->getMaxHits(), Connection::PARAM_INT)
);
// When max hits is set, exclude records which explicitly disabled the hitcount feature
$constraints[] = $queryBuilder->expr()->eq(
'disable_hitcount',
$queryBuilder->createNamedParameter(0, Connection::PARAM_INT)
);
}
if ($demand->hasCreationType()) {
$constraints[] = $queryBuilder->expr()->eq(
'creation_type',
$queryBuilder->createNamedParameter($demand->getCreationType(), Connection::PARAM_INT)
);
}
if ($demand->hasProtected()) {
$constraints[] = $queryBuilder->expr()->eq(
'protected',
$queryBuilder->createNamedParameter($demand->getProtected(), Connection::PARAM_INT)
);
}
if ($demand->hasIntegrityStatus()) {
$constraints[] = $queryBuilder->expr()->eq(
'integrity_status',
$queryBuilder->createNamedParameter($demand->getIntegrityStatus())
);
}
if (!empty($constraints)) {
$queryBuilder->where(...$constraints);
}
return $queryBuilder;
}
/**
* Get all used hosts
*/
public function findHostsOfRedirects(?string $type = null): array
{
return $this->getGroupedRows('source_host', 'name', $type);
}
/**
* Get all used status codes
*/
public function findStatusCodesOfRedirects(?string $type = null): array
{
return $this->getGroupedRows('target_statuscode', 'code', $type);
}
/**
* Get all used creation types
*/
public function findCreationTypes(?string $type = null): array
{
$types = [];
$availableTypes = $this->schema->getField('creation_type')->getConfiguration()['items'];
foreach ($this->getGroupedRows('creation_type', 'type', $type) as $row) {
foreach ($availableTypes as $availableType) {
if ($availableType['value'] === $row['type']) {
$types[$row['type']] = $availableType['label'];
}
}
}
return $types;
}
/**
* Get all used integrity status codes
*/
public function findIntegrityStatusCodes(?string $type = null): array
{
$statusCodes = [];
$availableStatusCodes = $this->schema->getField('integrity_status')->getConfiguration()['items'];
foreach ($this->getGroupedRows('integrity_status', 'status_code', $type) as $row) {
foreach ($availableStatusCodes as $availableStatusCode) {
if ($availableStatusCode['value'] === $row['status_code']) {
$statusCodes[$row['status_code']] = $availableStatusCode['label'];
}
}
}
return $statusCodes;
}
/**
* Get all available redirect_types
*/
public function findRedirectTypes(): array
{
// Admin: Direct SQL query with GROUP BY
if ($this->getBackendUser()->isAdmin()) {
$result = $this->getQueryBuilder()
->select('redirect_type')
->from('sys_redirect')
->groupBy('redirect_type')
->executeQuery()
->fetchAllAssociative();
return array_column($result, 'redirect_type');
}
// Non-admin: GROUP BY + SQL source host filter + minimal PHP target filtering
$queryBuilder = $this->getQueryBuilder()
->select('redirect_type', 'source_host', 'target')
->from('sys_redirect')
->groupBy('redirect_type', 'source_host', 'target');
try {
$this->addSourceHostConstraint($queryBuilder);
} catch (StopQueryException) {
return [];
}
$redirects = $queryBuilder
->executeQuery()
->fetchAllAssociative();
$filteredRedirects = $this->sortOutInaccessibleRedirects($redirects);
return array_values(array_unique(array_column($filteredRedirects, 'redirect_type')));
}
/**
* @return list<array<string, scalar|null>>
*/
protected function getGroupedRows(string $field, string $as, ?string $type = 'default'): array
{
// Admin: Direct SQL query
if ($this->getBackendUser()->isAdmin()) {
$queryBuilder = $this->getQueryBuilder()
->select(sprintf('%s as %s', $field, $as))
->from('sys_redirect')
->orderBy($field)
->groupBy($field);
if ($type !== null) {
$queryBuilder->where($queryBuilder->expr()->eq('redirect_type', $queryBuilder->createNamedParameter($type)));
}
return $queryBuilder
->executeQuery()
->fetchAllAssociative();
}
// Non-admin: Need to include source_host and target for filtering
$fields = [$field];
if ($field !== 'source_host') {
$fields[] = 'source_host';
}
if ($field !== 'target') {
$fields[] = 'target';
}
$queryBuilder = $this->getQueryBuilder()
->select(...$fields)
->from('sys_redirect')
->orderBy($field)
->groupBy(...$fields);
if ($type !== null) {
$queryBuilder->where($queryBuilder->expr()->eq('redirect_type', $queryBuilder->createNamedParameter($type)));
}
try {
$this->addSourceHostConstraint($queryBuilder);
} catch (StopQueryException) {
return [];
}
$redirects = $queryBuilder
->executeQuery()
->fetchAllAssociative();
$filteredRedirects = $this->sortOutInaccessibleRedirects($redirects);
return array_map(
static fn(mixed $value) => [$as => $value],
array_values(array_unique(array_column($filteredRedirects, $field))),
);
}
protected function getQueryBuilder(): QueryBuilder
{
$queryBuilder = $this->connectionPool->getQueryBuilderForTable('sys_redirect');
$queryBuilder->getRestrictions()
->removeAll()
->add(GeneralUtility::makeInstance(DeletedRestriction::class));
return $queryBuilder;
}
public function removeByDemand(Demand $demand): void
{
$queryBuilder = $this->connectionPool
->getQueryBuilderForTable('sys_redirect');
$queryBuilder
->delete('sys_redirect')
->where(
$queryBuilder->expr()->eq('protected', $queryBuilder->createNamedParameter(0, Connection::PARAM_INT))
);
if ($demand->hasMaxHits()) {
$queryBuilder->andWhere(
$queryBuilder->expr()->lt('hitcount', $queryBuilder->createNamedParameter($demand->getMaxHits(), Connection::PARAM_INT))
);
}
if ($demand->hasSourceHosts()) {
$queryBuilder
->andWhere('source_host IN (:domains)')
->setParameter('domains', $demand->getSourceHosts(), Connection::PARAM_STR_ARRAY);
}
if ($demand->hasStatusCodes()) {
$queryBuilder
->andWhere('target_statuscode IN (:statusCodes)')
->setParameter('statusCodes', $demand->getStatusCodes(), Connection::PARAM_INT_ARRAY);
}
if ($demand->hasOlderThan()) {
$timeStamp = $demand->getOlderThan()->getTimestamp();
$queryBuilder->andWhere(
$queryBuilder->expr()->lt('createdon', $queryBuilder->createNamedParameter($timeStamp, Connection::PARAM_INT))
);
}
if ($demand->hasSourcePath()) {
$queryBuilder
->andWhere($queryBuilder->expr()->like('source_path', ':path'))
->setParameter('path', $demand->getSourcePath());
}
if ($demand->hasCreationType()) {
$queryBuilder->andWhere(
$queryBuilder->expr()->eq('creation_type', $queryBuilder->createNamedParameter($demand->getCreationType(), Connection::PARAM_INT))
);
}
if ($demand->hasIntegrityStatus()) {
$queryBuilder->andWhere(
$queryBuilder->expr()->eq('integrity_status', $queryBuilder->createNamedParameter($demand->getIntegrityStatus(), Connection::PARAM_STR))
);
}
$queryBuilder->executeStatement();
}
/**
* @param list<non-empty-array> $redirects
* @return list<non-empty-array>
*/
protected function sortOutInaccessibleRedirects(array $redirects): array
{
return array_filter($redirects, $this->redirectPermissionGuard->isAllowedRedirect(...));
}
}
+24
View File
@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Redirects\Repository;
/**
* Class signaling that the query should be stopped.
* @internal
*/
class StopQueryException extends \RuntimeException {}
@@ -0,0 +1,125 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Redirects\Security;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use TYPO3\CMS\Backend\Utility\BackendUtility;
use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
use TYPO3\CMS\Core\Cache\Frontend\FrontendInterface;
use TYPO3\CMS\Core\LinkHandling\Exception\UnknownLinkHandlerException;
use TYPO3\CMS\Core\LinkHandling\Exception\UnknownUrnException;
use TYPO3\CMS\Core\LinkHandling\LinkService;
use TYPO3\CMS\Core\LinkHandling\TypoLinkCodecService;
use TYPO3\CMS\Core\Resource\FileInterface;
use TYPO3\CMS\Core\Type\Bitmask\Permission;
use TYPO3\CMS\Redirects\Data\SourceHostProvider;
/**
* Security guard to validate access to sys_redirect records for the current backend user.
*
* @internal
*/
final class RedirectPermissionGuard
{
/**
* @var list<non-empty-string>|null
*/
private ?array $allowedHosts = null;
public function __construct(
private readonly LinkService $linkService,
private readonly TypoLinkCodecService $typoLinkCodecService,
private readonly SourceHostProvider $sourceHostProvider,
#[Autowire(service: 'cache.runtime')]
private readonly FrontendInterface $cache,
) {}
public function isAllowedRedirect(array $redirect): bool
{
if ($this->getBackendUser()->isAdmin()) {
return true;
}
return $this->isAllowedSourceHost($redirect['source_host'] ?? '')
&& $this->isAllowedTarget($redirect['target'] ?? '');
}
public function getAllowedHosts(): array
{
$this->allowedHosts ??= $this->sourceHostProvider->getHosts(true);
return $this->allowedHosts;
}
private function isAllowedSourceHost(string $host): bool
{
return in_array($host, $this->getAllowedHosts(), true);
}
private function isAllowedTarget(string $target): bool
{
$cacheIdentifier = 'RedirectPermissionGuard-isAllowedTarget-' . md5($target);
if ($this->cache->has($cacheIdentifier)) {
return $this->cache->get($cacheIdentifier);
}
$result = true;
$linkParameterParts = $this->typoLinkCodecService->decode($target);
$redirectTarget = $linkParameterParts['url'];
if (str_starts_with($redirectTarget, 't3://')) {
try {
$resolvedLink = $this->linkService->resolveByStringRepresentation($redirectTarget);
if ((int)($resolvedLink['pageuid'] ?? 0) > 0) {
$result = $this->canAccessPage((int)$resolvedLink['pageuid']);
} elseif (($resolvedLink['file'] ?? null) instanceof FileInterface) {
$result = $this->canAccessFile($resolvedLink['file']);
}
} catch (UnknownUrnException|UnknownLinkHandlerException) {
}
}
$this->cache->set($cacheIdentifier, $result);
return $result;
}
private function canAccessPage(int $pageUid): bool
{
$page = BackendUtility::getRecord('pages', $pageUid, '*', '', false);
// If the page does no longer exist, we allow access to the redirect
if ($page === null) {
return true;
}
return $this->getBackendUser()->doesUserHaveAccess($page, Permission::PAGE_SHOW);
}
private function canAccessFile(FileInterface $file): bool
{
return $file->getStorage()->checkFileActionPermission('read', $file);
}
private function getBackendUser(): BackendUserAuthentication
{
return $GLOBALS['BE_USER'];
}
}
+200
View File
@@ -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;
}
}
+130
View File
@@ -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);
}
}
+511
View File
@@ -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');
}
}
+75
View File
@@ -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;
}
}
+409
View File
@@ -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');
}
}
@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Redirects\UserFunctions;
use TYPO3\CMS\Core\Configuration\Features;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Display condition evaluating the feature toggle "redirects.hitCount"
* @internal This class is a specific TYPO3 display condition implementation and is not part of the Public TYPO3 API.
*/
class HitCountDisplayCondition
{
/**
* Check whether the redirects hit count is globally enabled
*/
public function isEnabled(): bool
{
return GeneralUtility::makeInstance(Features::class)->isFeatureEnabled('redirects.hitCount');
}
}
+28
View File
@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Redirects\Utility;
/**
* Constants for redirect conflicts used by the core.
*/
class RedirectConflict
{
public const NO_CONFLICT = 'no_conflict';
public const SELF_REFERENCE = 'self_reference';
public const INVALID_TARGET = 'invalid_target';
}
@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Redirects\ViewHelpers;
use TYPO3\CMS\Backend\Utility\BackendUtility;
use TYPO3\CMS\Core\LinkHandling\Exception\UnknownLinkHandlerException;
use TYPO3\CMS\Core\LinkHandling\Exception\UnknownUrnException;
use TYPO3\CMS\Core\LinkHandling\LinkService;
use TYPO3Fluid\Fluid\Core\ViewHelper\AbstractViewHelper;
/**
* ViewHelper to check for t3://page links within a redirect,
* returning the page id.
*
* ```
* <rd:targetPageRecord target="{redirect.target}" />
* ```
*
* @internal
*/
final class TargetPageRecordViewHelper extends AbstractViewHelper
{
public function __construct(
private readonly LinkService $linkService
) {}
public function initializeArguments(): void
{
$this->registerArgument('target', 'string', 'The target of the redirect.', true);
}
/**
* Renders the page ID
*/
public function render(): array
{
if (!str_starts_with($this->arguments['target'] ?? '', 't3://page')) {
return [];
}
try {
$resolvedLink = $this->linkService->resolveByStringRepresentation($this->arguments['target']);
if (!($resolvedLink['pageuid'] ?? '')) {
return [];
}
return BackendUtility::getRecord('pages', $resolvedLink['pageuid']) ?? [];
} catch (UnknownUrnException|UnknownLinkHandlerException) {
return [];
}
}
}