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
+1
View File
@@ -0,0 +1 @@
/vendor/
+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 [];
}
}
}
+34
View File
@@ -0,0 +1,34 @@
<?php
use TYPO3\CMS\Redirects\Controller;
/**
* Definitions for routes provided by EXT:backend
* Contains all AJAX-based routes for entry points
*
* Currently the "access" property is only used so no token creation + validation is made
* but will be extended further.
*/
return [
// Revert Correlation
'redirects_revert_correlation' => [
'path' => '/redirects/revert/correlation',
'methods' => ['POST'],
'target' => Controller\RecordHistoryRollbackController::class . '::revertCorrelation',
],
// Endpoint to generate a Short URL
'short_url_generate' => [
'path' => '/short-url/generate',
'methods' => ['POST'],
'target' => Controller\ShortUrlGeneratorController::class . '::generate',
],
// Endpoint to validate a Short URL for uniqueness
'short_url_validate' => [
'path' => '/short-url/validate',
'methods' => ['POST'],
'target' => Controller\ShortUrlGeneratorController::class . '::validate',
],
];
+57
View File
@@ -0,0 +1,57 @@
<?php
use TYPO3\CMS\Redirects\Controller\ManagementController;
use TYPO3\CMS\Redirects\Controller\QrCodeModuleController;
use TYPO3\CMS\Redirects\Controller\ShortUrlModuleController;
/**
* Definitions for modules provided by EXT:redirects
*/
return [
'redirects' => [
'parent' => 'link_management',
'access' => 'user',
'path' => '/module/link-management/redirects',
'iconIdentifier' => 'module-redirects',
'labels' => 'redirects.modules.redirects',
'aliases' => ['site_redirects'],
'routes' => [
'_default' => [
'target' => ManagementController::class . '::handleRequest',
],
],
'moduleData' => [
'redirectType' => 'default',
],
],
'qrcodes' => [
'parent' => 'link_management',
'access' => 'user',
'path' => '/module/link-management/qrcodes',
'iconIdentifier' => 'module-qrcode',
'labels' => 'redirects.modules.qrcodes',
'routes' => [
'_default' => [
'target' => QrCodeModuleController::class . '::handleRequest',
],
],
'moduleData' => [
'redirectType' => 'qrcode',
],
],
'short_urls' => [
'parent' => 'link_management',
'access' => 'user',
'path' => '/module/link-management/short-urls',
'iconIdentifier' => 'module-urls',
'labels' => 'redirects.modules.short_urls',
'routes' => [
'_default' => [
'target' => ShortUrlModuleController::class . '::handleRequest',
],
],
'moduleData' => [
'redirectType' => 'short_url',
],
],
];
+8
View File
@@ -0,0 +1,8 @@
<?php
return [
'mimetypes-x-sys_redirect' => [
'provider' => \TYPO3\CMS\Core\Imaging\IconProvider\SvgIconProvider::class,
'source' => 'EXT:redirects/Resources/Public/Icons/mimetypes-x-sys_redirect.svg',
],
];
+11
View File
@@ -0,0 +1,11 @@
<?php
return [
'dependencies' => [
'backend',
'core',
],
'imports' => [
'@typo3/redirects/' => 'EXT:redirects/Resources/Public/JavaScript/',
],
];
+18
View File
@@ -0,0 +1,18 @@
<?php
/**
* Definitions for middlewares provided by EXT:redirects
*/
return [
'frontend' => [
'typo3/cms-redirects/redirecthandler' => [
'target' => \TYPO3\CMS\Redirects\Http\Middleware\RedirectHandler::class,
'before' => [
'typo3/cms-frontend/base-redirect-resolver',
],
'after' => [
'typo3/cms-frontend/authentication',
],
],
],
];
+20
View File
@@ -0,0 +1,20 @@
services:
_defaults:
autowire: true
autoconfigure: true
public: false
TYPO3\CMS\Redirects\:
resource: '../Classes/*'
extension.configuration.redirects:
class: 'array'
factory:
- '@TYPO3\CMS\Core\Configuration\ExtensionConfiguration'
- 'get'
arguments:
- 'redirects'
TYPO3\CMS\Redirects\Configuration\CheckIntegrityConfiguration:
arguments:
$extensionConfiguration: '@extension.configuration.redirects'
+1
View File
@@ -0,0 +1 @@
name: typo3/redirects
+35
View File
@@ -0,0 +1,35 @@
<?xml version="1.0" encoding="UTF-8"?>
<xliff xmlns="urn:oasis:names:tc:xliff:document:1.2" version="1.2">
<file source-language="en" datatype="plaintext" original="EXT:redirects/Configuration/Sets/redirects/labels.xlf" date="2024-09-10T12:30:00Z" product-name="redirects">
<header/>
<body>
<trans-unit id="label">
<source>Redirects</source>
</trans-unit>
<trans-unit id="categories.redirects">
<source>Redirects</source>
</trans-unit>
<trans-unit id="settings.redirects.autoUpdateSlugs">
<source>Automatically update slugs of all sub pages</source>
</trans-unit>
<trans-unit id="settings.redirects.autoCreateRedirects">
<source>Automatically create redirects for pages with a new slug (works only in LIVE workspace)</source>
</trans-unit>
<trans-unit id="settings.description.redirects.autoCreateRedirects">
<source>This feature works only in LIVE workspace.</source>
</trans-unit>
<trans-unit id="settings.redirects.redirectTTL">
<source>Time To Live in days for redirect records to be created</source>
</trans-unit>
<trans-unit id="settings.description.redirects.redirectTTL">
<source>The value `0` disables expiration.</source>
</trans-unit>
<trans-unit id="settings.redirects.httpStatusCode">
<source>HTTP status code for automatically created redirects</source>
</trans-unit>
<trans-unit id="settings.description.redirects.httpStatusCode">
<source>See https://developer.mozilla.org/en-US/docs/Web/HTTP/Redirections#Temporary_redirections for more information.</source>
</trans-unit>
</body>
</file>
</xliff>
@@ -0,0 +1,20 @@
categories:
redirects: ~
settings:
redirects.autoUpdateSlugs:
type: bool
default: true
category: redirects
redirects.autoCreateRedirects:
type: bool
default: true
category: redirects
redirects.redirectTTL:
type: int
default: 0
category: redirects
redirects.httpStatusCode:
type: int
default: 307
category: redirects
@@ -0,0 +1,5 @@
<?php
defined('TYPO3') or die();
$GLOBALS['TCA']['sys_redirect']['types']['short_url']['columnsOverrides']['source_path']['config']['placeholder'] = 'redirects.module_redirect:short_url.source_path.placeholder';
+334
View File
@@ -0,0 +1,334 @@
<?php
use TYPO3\CMS\Redirects\Utility\RedirectConflict;
return [
'ctrl' => [
'title' => 'LLL:EXT:redirects/Resources/Private/Language/locallang_db.xlf:sys_redirect',
'descriptionColumn' => 'description',
'label' => 'source_host',
'label_alt' => 'source_path',
'label_alt_force' => true,
'crdate' => 'createdon',
'tstamp' => 'updatedon',
'hideTable' => true,
'versioningWS' => false,
'groupName' => 'system',
'default_sortby' => 'source_host, source_path',
'rootLevel' => -1,
'security' => [
'ignoreWebMountRestriction' => true,
'ignoreRootLevelRestriction' => true,
'ignorePageTypeRestriction' => true,
],
'delete' => 'deleted',
'enablecolumns' => [
'disabled' => 'disabled',
'starttime' => 'starttime',
'endtime' => 'endtime',
],
'typeicon_column' => 'redirect_type',
'typeicon_classes' => [
'default' => 'mimetypes-x-sys_redirect',
'qrcode' => 'actions-qrcode',
'short_url' => 'module-urls',
],
'type' => 'redirect_type',
],
'types' => [
'default' => [
'showitem' => '
--div--;core.form.tabs:general, --palette--;;source, --palette--;;targetdetails, protected, --palette--;;internals,
--div--;redirects.db:tabs.redirectCount, disable_hitcount, hitcount, lasthiton, createdon,
--div--;core.form.tabs:access, --palette--;;visibility,
--div--;core.form.tabs:notes, description, redirect_type',
'columnsOverrides' => [
'source_host' => [
'config' => [
'default' => '*',
],
],
],
],
'qrcode' => [
'title' => 'LLL:EXT:redirects/Resources/Private/Language/locallang_db.xlf:sys_redirect.redirect_type.qr_code',
'showitem' => '
--div--;core.form.tabs:general, --palette--;;qrcode_target,qrcode_display,
--div--;redirects.db:tabs.redirectCount, disable_hitcount, hitcount, lasthiton, createdon,
--div--;core.form.tabs:access, --palette--;;visibility,
--div--;core.form.tabs:notes, description, redirect_type
',
],
'short_url' => [
'title' => 'LLL:EXT:redirects/Resources/Private/Language/locallang_db.xlf:sys_redirect.redirect_type.short_url',
'showitem' => '
--div--;core.form.tabs:general, --palette--;;short_url_target,
--div--;redirects.db:tabs.redirectCount, disable_hitcount, hitcount, lasthiton, createdon,
--div--;core.form.tabs:access, --palette--;;visibility,
--div--;core.form.tabs:notes, description, redirect_type
',
],
],
'palettes' => [
'visibility' => [
'showitem' => 'disabled, --linebreak--, starttime, endtime',
],
'source' => [
'showitem' => 'source_host, source_path, --linebreak--, respect_query_parameters, is_regexp',
],
'targetdetails' => [
'showitem' => 'target, target_statuscode, --linebreak--, force_https, keep_query_parameters',
],
'internals' => [
'showitem' => 'creation_type, integrity_status, --linebreak--, createdby',
],
'qrcode_target' => [
'showitem' => 'source_host, target, --linebreak--, createdby, force_https',
],
'short_url_target' => [
'showitem' => 'short_url, --linebreak--, target, --linebreak--, createdby, force_https',
],
],
'columns' => [
'redirect_type' => [
'config' => [
'type' => 'passthrough',
'default' => 'default',
],
],
'qrcode_display' => [
'config' => [
'type' => 'none',
'renderType' => 'qrCode',
],
],
'short_url' => [
'label' => 'LLL:EXT:redirects/Resources/Private/Language/locallang_db.xlf:sys_redirect.short_url',
'description' => 'LLL:EXT:redirects/Resources/Private/Language/locallang_db.xlf:sys_redirect.short_url.description',
'config' => [
'type' => 'none',
'renderType' => 'shortUrl',
'fieldControl' => [
'shortUrlGenerator' => [
'renderType' => 'shortUrlGenerator',
'options' => [
'title' => 'LLL:EXT:redirects/Resources/Private/Language/locallang_db.xlf:sys_redirect.short_url.shortUrlGenerator',
],
],
],
],
],
'source_host' => [
'label' => 'LLL:EXT:redirects/Resources/Private/Language/locallang_db.xlf:sys_redirect.source_host',
'config' => [
'type' => 'input',
'required' => true,
'eval' => 'trim,' . \TYPO3\CMS\Redirects\Evaluation\SourceHost::class,
// items will be extended by local sys_domain records using dataprovider TYPO3\CMS\Redirects\FormDataProvider\ValuePickerItemDataProvider
'valuePicker' => [],
],
],
'source_path' => [
'label' => 'LLL:EXT:redirects/Resources/Private/Language/locallang_db.xlf:sys_redirect.source_path',
'config' => [
'type' => 'input',
'size' => 30,
'required' => true,
'eval' => 'trim',
'placeholder' => 'LLL:EXT:redirects/Resources/Private/Language/locallang_module_redirect.xlf:source_path.placeholder',
'max' => 2048,
],
],
'force_https' => [
'exclude' => true,
'label' => 'LLL:EXT:redirects/Resources/Private/Language/locallang_db.xlf:sys_redirect.force_https.0',
'config' => [
'type' => 'check',
'renderType' => 'checkboxToggle',
'default' => 0,
],
],
'keep_query_parameters' => [
'exclude' => true,
'label' => 'LLL:EXT:redirects/Resources/Private/Language/locallang_db.xlf:sys_redirect.keep_query_parameters.0',
'config' => [
'type' => 'check',
'renderType' => 'checkboxToggle',
'default' => 0,
],
],
'respect_query_parameters' => [
'exclude' => true,
'label' => 'LLL:EXT:redirects/Resources/Private/Language/locallang_db.xlf:sys_redirect.respect_query_parameters.0',
'config' => [
'type' => 'check',
'renderType' => 'checkboxToggle',
'default' => 0,
],
],
'target' => [
'label' => 'LLL:EXT:redirects/Resources/Private/Language/locallang_db.xlf:sys_redirect.target',
'config' => [
'type' => 'link',
'required' => true,
'allowedTypes' => ['page', 'file', 'url', 'record'],
'appearance' => [
'allowedOptions' => ['params'],
],
],
],
'target_statuscode' => [
'exclude' => true,
'label' => 'LLL:EXT:redirects/Resources/Private/Language/locallang_db.xlf:sys_redirect.target_statuscode',
'config' => [
'type' => 'select',
'renderType' => 'selectSingle',
'items' => [
[
'label' => 'LLL:EXT:redirects/Resources/Private/Language/locallang_db.xlf:sys_redirect.target_statuscode.301',
'value' => 301,
'group' => 'change',
],
[
'label' => 'LLL:EXT:redirects/Resources/Private/Language/locallang_db.xlf:sys_redirect.target_statuscode.302',
'value' => 302,
'group' => 'change',
],
[
'label' => 'LLL:EXT:redirects/Resources/Private/Language/locallang_db.xlf:sys_redirect.target_statuscode.303',
'value' => 303,
'group' => 'change',
],
[
'label' => 'LLL:EXT:redirects/Resources/Private/Language/locallang_db.xlf:sys_redirect.target_statuscode.307',
'value' => 307,
'group' => 'keep',
],
[
'label' => 'LLL:EXT:redirects/Resources/Private/Language/locallang_db.xlf:sys_redirect.target_statuscode.308',
'value' => 308,
'group' => 'keep',
],
],
'itemGroups' => [
'keep' => 'LLL:EXT:redirects/Resources/Private/Language/locallang_db.xlf:sys_redirect.target_statuscode.keep',
'change' => 'LLL:EXT:redirects/Resources/Private/Language/locallang_db.xlf:sys_redirect.target_statuscode.change',
],
'default' => 307,
],
],
'hitcount' => [
'exclude' => true,
'label' => 'LLL:EXT:redirects/Resources/Private/Language/locallang_db.xlf:sys_redirect.hitcount',
'config' => [
'type' => 'input',
'size' => 5,
'default' => 0,
'readOnly' => true,
],
'displayCond' => 'USER:TYPO3\CMS\Redirects\UserFunctions\HitCountDisplayCondition->isEnabled',
],
'lasthiton' => [
'exclude' => true,
'label' => 'LLL:EXT:redirects/Resources/Private/Language/locallang_db.xlf:sys_redirect.lasthiton',
'config' => [
'type' => 'datetime',
'readOnly' => true,
],
'displayCond' => 'USER:TYPO3\CMS\Redirects\UserFunctions\HitCountDisplayCondition->isEnabled',
],
'createdon' => [
'exclude' => true,
'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_general.xlf:LGL.creationDate',
'config' => [
'type' => 'datetime',
'readOnly' => true,
],
'displayCond' => 'USER:TYPO3\CMS\Redirects\UserFunctions\HitCountDisplayCondition->isEnabled',
],
'disable_hitcount' => [
'exclude' => true,
'label' => 'LLL:EXT:redirects/Resources/Private/Language/locallang_db.xlf:sys_redirect.hitcountState',
'config' => [
'type' => 'check',
'renderType' => 'checkboxLabeledToggle',
'items' => [
[
'label' => '',
'labelChecked' => 'LLL:EXT:core/Resources/Private/Language/locallang_general.xlf:LGL.enabled',
'labelUnchecked' => 'LLL:EXT:core/Resources/Private/Language/locallang_general.xlf:LGL.disabled',
'invertStateDisplay' => true,
],
],
],
'displayCond' => 'USER:TYPO3\CMS\Redirects\UserFunctions\HitCountDisplayCondition->isEnabled',
],
'is_regexp' => [
'exclude' => true,
'label' => 'LLL:EXT:redirects/Resources/Private/Language/locallang_db.xlf:sys_redirect.is_regexp',
'config' => [
'type' => 'check',
'renderType' => 'checkboxToggle',
],
],
'protected' => [
'exclude' => true,
'label' => 'LLL:EXT:redirects/Resources/Private/Language/locallang_db.xlf:sys_redirect.protected',
'description' => 'LLL:EXT:redirects/Resources/Private/Language/locallang_db.xlf:sys_redirect.protected.description',
'config' => [
'type' => 'check',
'renderType' => 'checkboxToggle',
],
],
'creation_type' => [
'label' => 'LLL:EXT:redirects/Resources/Private/Language/locallang_db.xlf:sys_redirect.creation_type',
'description' => 'LLL:EXT:redirects/Resources/Private/Language/locallang_db.xlf:sys_redirect.creation_type.description',
'config' => [
'type' => 'select',
'renderType' => 'selectSingle',
'items' => [
[
'label' => 'LLL:EXT:redirects/Resources/Private/Language/locallang_db.xlf:sys_redirect.creation_type.0',
'value' => 0,
],
[
'label' => 'LLL:EXT:redirects/Resources/Private/Language/locallang_db.xlf:sys_redirect.creation_type.1',
'value' => 1,
],
],
'default' => 1,
'readOnly' => true,
],
],
'createdby' => [
'label' => 'LLL:EXT:redirects/Resources/Private/Language/locallang_db.xlf:sys_redirect.createdby',
'description' => 'LLL:EXT:redirects/Resources/Private/Language/locallang_db.xlf:sys_redirect.createdby.description',
'config' => [
'type' => 'passthrough',
'renderType' => 'creationInformation',
'default' => 0,
],
],
'integrity_status' => [
'label' => 'LLL:EXT:redirects/Resources/Private/Language/locallang_db.xlf:sys_redirect.integrity_status',
'description' => 'LLL:EXT:redirects/Resources/Private/Language/locallang_db.xlf:sys_redirect.integrity_status.description',
'config' => [
'type' => 'select',
'renderType' => 'selectSingle',
'dbFieldLength' => 180,
'default' => RedirectConflict::NO_CONFLICT,
'items' => [
[
'label' => 'LLL:EXT:redirects/Resources/Private/Language/locallang_db.xlf:sys_redirect.integrity_status.no_conflict',
'value' => RedirectConflict::NO_CONFLICT,
],
[
'label' => 'LLL:EXT:redirects/Resources/Private/Language/locallang_db.xlf:sys_redirect.integrity_status.self_reference',
'value' => RedirectConflict::SELF_REFERENCE,
],
],
'readOnly' => true,
],
],
],
];
+136
View File
@@ -0,0 +1,136 @@
.. include:: /Includes.rst.txt
.. _glossary:
.. _basics:
======
Basics
======
This page defines and explains some basics and basic terms which are not
specific to EXT:redirects.
.. todo: add term for t3:// URI (e.g. typo3 URI, linkhandler URI, etc.) after term is clarified
.. see https://forge.typo3.org/issues/95820
.. _basics-url:
Components of a URL
===================
A URL contains the following components:
`scheme://host:port/path?query-parameters#fragment`
Example: https://example.org/path?key=value#c123
If the following terms are used in this documentation, it refers to the parts
of the URL:
- scheme
- host
- path
- query parameters
- fragment
.. _http-status-codes:
HTTP status codes
=================
When redirecting, a HTTP status code is sent to the client (usually a browser
or a bot). This status code informs the client
about the type of redirect. We differentiate between a permanent and a temporary
redirect.
For a full list of possible HTTP status codes for redirects (e.g. 301, 302, 307
etc), see https://developer.mozilla.org/en-US/docs/Web/HTTP/Status.
* `301 <https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/301>`__:
Moved permanently
* `302 <https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/302>`__:
Found
* `303 <https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/303>`__:
See other
* `307 <https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/307>`__:
Temporary redirect
* `308 <https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/308>`__:
Permanent redirect
.. note::
Which redirect to use for which use cases is beyond the scope of this
documentation. We give you some pointers here, but information like this
can be outdated and it is best to read up on this elsewhere.
As rule of thumb:
There are "temporary" and "permanent" redirects. 301 and 308 are "permanent"
redirects.
.. attention::
Dont use a 301 if you ever want to use that specific (source) URL ever again.
Source: `6 questions about redirects for SEO (Yoast)
<https://yoast.com/6-questions-about-redirects-for-seo/>`__
.. attention::
For routine redirect tasks, 301 (permanent redirect) and 307 (temporary
redirect) status codes can be used depending on what type of change you
are implementing on your website.
Source: `A Technical SEO Guide to Redirects (SEJ)
<https://www.searchenginejournal.com/technical-seo/redirects/>`__
For automatically created redirects it is not recommended to use 301. You can
use 307, which is also the default in the redirects extension. However,
if you create redirects manually, it **may** make sense to use 301 for these.
With permanent redirects (301 and 308) the "link juice" (ranking factor) is
transferred to the redirect target. The search engines are notified this way
that the URL has changed permanently and that they should update their index
accordingly. Thus, from SEO point of view, permanent redirects are often a good
choice. If domains are changed or sites restructured, 301 are often used.
.. _redirect-chain:
Redirect chain
==============
Contrary to the redirects loops, the pages can still be loaded. Redirect
chains are inefficient because a number of redirects must be processed before
the final page is loaded.
Examples for redirect chains:
- `/a => /b => /c` (it would be more efficient if `/a` redirected to `/c`
directly and `/b` redirected to `/c`)
.. _redirect-loop:
Redirect loop
=============
A number of one or more redirects which will cause a loop by redirecting back
to the origin. The page can no longer be loaded and a HTTP status code 500 is
usually returned.
Examples for redirect loops:
- `/a => /a` (source and target for a redirect resolve to the same URL)
- `/a => /b => /a`
.. _slug:
Slug
====
A slug is the part of the URL path specific to the page. The slug is stored as
:sql:`pages.slug` in the database. The slug does not necessarily exactly reflect
the URL path which is used in the URL to access the page. The actual URL may
depend on the entry point configured in the site configuration, additional route
enhancers and decorators.
Example: A slug `/path` is used, the final URL may be
`https://example.org/en/path.html`.
+120
View File
@@ -0,0 +1,120 @@
.. include:: /Includes.rst.txt
.. _best-practices:
==============
Best practices
==============
Here are some general tips for managing redirects:
- Check for conflicts regularly with
:ref:`redirects:checkintegrity <redirects-checkintegrity>`. This is no
longer as much an issue as with previous versions, because it was resolved
with `patch 68202 <https://review.typo3.org/c/Packages/TYPO3.CMS/+/68202>`__.
Since this patch, a path is no longer used for the target. A redirect is
constructed using the page ID, e.g. `t3://page?uid=1` as target. This means
the redirect will still work, even if the slug changes again. This way, it
is less likely that :ref:`redirect loops <redirect-loop>` and
:ref:`redirect chains <redirect-chain>` are created because the redirect
always redirects directly to the target page.
- Check number of redirects and regularly clean out unnecessary redirects,
e.g. with :ref:`redirects:cleanup <redirects-cleanup>`. If you use the
:ref:`hit counter <hit-counter>`, be aware that it comes with a small
performance impact.
- :ref:`"Redirect chains" <redirect-chain>` are not as much a problem, but can
become inefficient. A "redirect chain" are several redirects which must be
followed until the destination is reached. Ideally, these should be merged.
.. _best-practices-editors:
Editors
=======
Well curated content and editors which have a good understanding of SEO and
possible problems with redirects are a good idea in any case. TYPO3 comes
with extensive :ref:`permission <t3coreapi:access-users-groups>` and
:doc:`workspaces <ext_workspaces:Index>` management - which gives you the
possibility to only grant advanced editor groups access to parts of the content
(e.g. pages, redirect module) which they are well equipped to handle.
- If you give editors access to the redirects module, make sure that they
understand the usage and for example do not create
:ref:`redirect loops <redirect-loop>`.
- Often changing slugs comes with a cost. Redirects are a counter measure
so that pages with changing slugs are still accessible but a better
strategy is to only change slugs when absolutely necessary.
.. _best-practices-performance:
Performance
===========
With a certain number of redirects and depending on your setup, performance
problems *may* occur through technical limitations.
The following rules of thumb should be followed:
- Restrict time-to-live [`ttl`] of redirects - manual and automatically
created.
- Cleanup regularly and remove outdated redirects.
- Recheck redirects and aggregate them on a manual basis to lower the number.
- Keep the number of redirects for the whole instance in a certain range.
- Instruct editors to be careful with slug changes and thus creating redirects
automatically, which may be unnecessary.
.. note::
Handling redirects through PHP applications has technical limitations,
even more if complex redirects like regexp-style redirects should be
supported. Thus, handling redirects with EXT:redirects is only suitable for
installations with a certain number of redirects.
It is recommended to monitor performance and - if necessary - export
redirects to your webserver configuration or load balancer.
.. _best-practices-troubleshooting-tools:
Troubleshooting tools
=====================
Since redirects are resolved in the web browser, it may be difficult to
troubleshoot. There are many tools available, for example you can
use a command line tool like `curl` to follow and show redirects or use the
online tool `Redirect detective <https://redirectdetective.com/>`__.
Redirect detective also detects redirect loops.
.. figure:: ../Images/RedirectDetective.png
:class: with-shadow
Output of Redirect Detective for `http://typo3.org`.
Example: Resolve redirects with curl (`-L` follows redirects):
.. code-block:: shell
curl -I -L -s -X GET http://example.org
Output:
.. code-block:: shell
HTTP/1.1 301 Moved Permanently
...
Location: http://example.com
....
HTTP/1.1 301 Moved Permanently
...
Location: https://example.com
....
HTTP/1.1 200 OK
...
As you can see, `http://example.org` is redirected twice, first to
`http://example.com` and then to the HTTPS variant.
.. note::
These are just two simple tools of many you can use.
+75
View File
@@ -0,0 +1,75 @@
.. include:: /Includes.rst.txt
.. _psr14events:
=============
PSR-14 events
=============
The following PSR-14 events are available to extend the functionality:
.. _AfterAutoCreateRedirectHasBeenPersistedEvent:
AfterAutoCreateRedirectHasBeenPersistedEvent
============================================
React on persisted auto-created redirects.
:ref:`More details <t3coreapi:AfterAutoCreateRedirectHasBeenPersistedEvent>`
.. _BeforeRedirectMatchDomainEvent:
BeforeRedirectMatchDomainEvent
==============================
Implement a custom redirect matching upon the loaded redirects or return a
matched redirect record from other sources.
:ref:`More details <t3coreapi:BeforeRedirectMatchDomainEvent>`
.. _AfterPageUrlsForSiteForRedirectIntegrityHaveBeenCollectedEvent:
AfterPageUrlsForSiteForRedirectIntegrityHaveBeenCollectedEvent
==========================
Fetch and alter the list of URLs checked when calling the :ref:`redirects:checkintegrity <redirects-checkintegrity>`
command.
:ref:`More details <t3coreapi:AfterPageUrlsForSiteForRedirectIntegrityHaveBeenCollectedEvent>`
.. _ModifyAutoCreateRedirectRecordBeforePersistingEvent:
ModifyAutoCreateRedirectRecordBeforePersistingEvent
===================================================
Modify the redirect record before it is persisted to the database.
:ref:`More details <t3coreapi:ModifyAutoCreateRedirectRecordBeforePersistingEvent>`
.. _ModifyRedirectManagementControllerViewDataEvent:
ModifyRedirectManagementControllerViewDataEvent
===============================================
Modify or enrich view data for the
:php:`\TYPO3\CMS\Redirects\Controller\ManagementController`.
:ref:`More details <t3coreapi:ModifyRedirectManagementControllerViewDataEvent>`
.. _RedirectTargetIntegrityCheckEvent:
RedirectTargetIntegrityCheckEvent
=================================
Validate redirect targets during the integrity check and flag broken redirects.
.. _RedirectWasHitEvent:
RedirectWasHitEvent
===================
Process the matched redirect further and adjust the PSR-7 response.
:ref:`More details <t3coreapi:RedirectWasHitEvent>`
.. _SlugRedirectChangeItemCreatedEvent:
SlugRedirectChangeItemCreatedEvent
==================================
Manage the redirect sources for which redirects should be created.
:ref:`More details <t3coreapi:SlugRedirectChangeItemCreatedEvent>`
Binary file not shown.

After

Width:  |  Height:  |  Size: 427 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

+1
View File
@@ -0,0 +1 @@
.. You can put central messages to display on all pages here
+59
View File
@@ -0,0 +1,59 @@
.. include:: /Includes.rst.txt
.. _start:
===============
TYPO3 Redirects
===============
:Extension key:
redirects
:Package name:
typo3/cms-redirects
:Version:
|release|
:Language:
en
:Author:
TYPO3 contributors
:License:
This document is published under the
`Creative Commons BY-NC-SA 4.0 <https://creativecommons.org/licenses/by-nc-sa/4.0/>`__
license.
:Rendered:
|today|
----
This extension makes it possible to create manual redirects, list existing
redirects and automatically create redirects on slug changes.
----
**Table of Contents:**
.. toctree::
:maxdepth: 2
:titlesonly:
Introduction/Index
Installation/Index
Setup/Index
Usage/Index
BestPractices/Index
Events/Index
KnownProblems/Index
Basics/Index
.. Meta Menu
.. toctree::
:hidden:
Sitemap
+56
View File
@@ -0,0 +1,56 @@
.. include:: /Includes.rst.txt
.. _installation:
============
Installation
============
This extension is part of the TYPO3 Core, but not installed by default.
.. contents:: Table of contents
:local:
.. _installation-composer:
Installation with Composer
==========================
Check whether you are already using the extension with:
.. code-block:: bash
composer show | grep redirects
This should either give you no result or something similar to:
.. code-block:: none
typo3/cms-redirects v12.4.11
If it is not installed yet, use the ``composer require`` command to install
the extension:
.. code-block:: bash
composer require typo3/cms-redirects
The given version depends on the version of the TYPO3 Core you are using.
.. _installation-non-composer:
Installation without Composer
=============================
In an installation without Composer, the extension is already shipped but might
not be activated yet. Activate it as follows:
#. In the backend, navigate to the :guilabel:`System > Extensions`
module.
#. Click the :guilabel:`Activate` icon for the Redirects extension.
.. figure:: /Images/InstallActivate.png
:class: with-border
:alt: Extension manager showing Redirects extension
Extension manager showing Redirects extension
+71
View File
@@ -0,0 +1,71 @@
.. include:: /Includes.rst.txt
.. _introduction:
============
Introduction
============
During the lifetime of a website, the URLs of pages often change. If no
countermeasures are in place, users will attempt to access pages that no
longer exist when browsing your site. Typically, when this occurs an error page is returned.
This is inefficient and impacts the user experience. When multiple missing pages or 404
/ 410 HTTP status codes are returned, the overall SEO ranking is negatively affected.
Changing URLs can have multiple reasons, sometimes the name of something changes
and the URL should reflect that or pages are restructured on the site.
There are many reasons as to why URLs are changed. This can include a restructure
of the site's pages and also occurs when the name of a page is changed.
and the URL in turn changes as well to reflect this.
HTTP redirects act as an important measure to guide users (and bots) to new
pages. This often happens in the background without the user noticing it
because the browser will automatically resolve the redirect.
This works similar to a forwarding request when you move house and your address
changes.
For more technical information on how redirects work, visit
`MDN Web Docs Redirections in HTTP <https://developer.mozilla.org/en-US/docs/Web/HTTP/Redirections>`__.
For more information about the types of redirects, see
:ref:`HTTP status codes <http-status-codes>`
.. _introduction-what:
What does it do?
================
The TYPO3 system extension EXT:redirects handles redirects within a TYPO3 site.
Features:
- Manually create redirects in the backend. The redirect information is
stored in the :sql:`sys_redirect` table.
- View and edit existing redirect records in the redirects backend module.
- Automatic redirect creation on slug changes (based on site configuration).
- Console commands to check the integrity and cleanup existing redirects.
- System reports that display information about any conflicting redirects.
.. note::
EXT:redirects does not handle redirects created via page types "*Link to External
URL*" (`pages.doktype=3`), "*Shortcut*" (`pages.doktype=4`) or redirects created
within the web server (e.g. :file:`.htaccess` or web server configuration).
.. _conventions:
Conventions
===========
Visit the :ref:`basics` page found at the end of this document for a general
definition of terms.
When describing parts of the user interface, we use the :guilabel:`gui label`
to mark texts within the UI.
Common names are formatted in *italics* (though this is not used everywhere to
ease readability).
Sometimes the topic of a paragraph is marked in **bold** to ease skimming of
pages for relevant content.
+25
View File
@@ -0,0 +1,25 @@
.. include:: /Includes.rst.txt
.. _known-problems:
==============
Known problems
==============
.. _usagePitfallsConstants:
Problem with constants in LinkHandler TSConfig
==============================================
It is important, that the `storagePid` is hard coded in the LinkHandler Page
TSConfig, because using constants, e.g. from the site configuration, won't work
here. :ref:`More details <t3coreapi:linkhandler-pagetsconfig>`
.. _known-problems-other:
Other known problems
====================
For more known problems, please refer to the
`open issues for "Redirect Handling"
<https://forge.typo3.org/projects/typo3cms-core/issues?utf8=✓&set_filter=1&f[]=category_id&op[category_id]==&v[category_id][]=1687&f[]=status_id&op[status_id]=o&f[]=&c[]=tracker&c[]=status&c[]=priority&c[]=subject&c[]=assigned_to&c[]=category&c[]=fixed_version&c[]=cf_7&group_by=&t[]=>`__
+380
View File
@@ -0,0 +1,380 @@
.. include:: /Includes.rst.txt
.. _setup:
=====
Setup
=====
The redirects extension requires no extra configuration once it is installed.
However, it is recommended to familiarize yourself with
the settings and commands outlined in this page. Depending on your site and how
editing is handled, changes in the configuration and regular maintenance may be
required.
.. _site-configuration:
Site configuration
==================
The core comes with the following site settings for redirects which can be
configured per site.
Configuration via backend module
---------------------------------
The redirect settings can be configured in the backend via
:guilabel:`Site Management > Settings`.
Configuration via YAML files
-----------------------------
Sites using site sets (TYPO3 v13+)
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
For sites using site sets, add the settings to
:file:`config/sites/<site>/settings.yaml`:
.. code-block:: yaml
redirects.autoCreateRedirects: false
redirects.autoUpdateSlugs: true
redirects.redirectTTL: 0
redirects.httpStatusCode: 307
Alternatively, you can define these settings in your site package at
:file:`mysitepackage/Configuration/Sets/mysiteset/settings.yaml` to provide
defaults for all sites using this site set.
Legacy site configuration (TYPO3 v12 and earlier)
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
In legacy installations without site sets, add the settings to
:file:`config/sites/<site>/config.yaml`:
.. code-block:: yaml
settings:
redirects:
autoUpdateSlugs: true
autoCreateRedirects: true
redirectTTL: 0
httpStatusCode: 307
.. hint::
In older installations, the file is found in
:file:`typo3conf/sites/<site>/config.yaml`.
Available settings
------------------
The following settings apply to **automatically created redirects**.
TYPO3 comes with working defaults. It is not necessary to configure these
settings if you use the defaults.
**autoUpdateSlugs**
Automatically update slugs of all sub pages (default: ``true``)
**autoCreateRedirects**
Automatically create redirects for pages with a new slug (works only in
LIVE workspace) (default: ``true``)
**redirectTTL**
Time To Live in days for redirect records to be created - ``0`` disables
TTL, no expiration (default: ``0``)
**httpStatusCode**
HTTP status code for automatically created redirects, see
`MDN: HTTP Redirections <https://developer.mozilla.org/en-US/docs/Web/HTTP/Redirections#Temporary_redirections>`__
(default: ``307``)
The `httpStatusCode` does not affect the default status code for manually created
redirects. This can be adjusted via TCA
:php:`$GLOBALS['TCA']['sys_redirect']['columns']['target_statuscode']['config']['default']`.
.. versionchanged:: 12.1
Since TYPO3 v12.1, automatically created redirect records are stored on the
configured root page ID of the site. Previously, they were initially stored
on the top root page or later on the changed page.
.. seealso::
The `settings` in the site configuration are generally explained in
"TYPO3 Explained" > :ref:`t3coreapi:sitehandling-settings`.
.. _console-commands:
Console commands
================
As for commands in general, it is possible to execute them via the command
line or via the TYPO3 scheduler in the backend.
Please see the general information about this in "TYPO3 Explained" >
:ref:`t3coreapi:symfony-console-commands-cli`.
We explain executing the commands from the command line here, it is recommended
to automate regular execution, e.g. via cron.
.. _redirects-cleanup:
redirects:cleanup
-----------------
The CLI command `redirects:cleanup` can be used to periodically cleanup existing
redirects under given conditions.
Use `-h` to see all options:
.. tabs::
.. group-tab:: Composer-based installation
.. code-block:: bash
vendor/bin/typo3 redirects:cleanup -h
.. group-tab:: Legacy installation
.. code-block:: bash
typo3/sysext/core/bin/typo3 redirects:cleanup -h
**Example 1:** Remove all redirects with less than 50 hits **and** older than 30
days.
.. tabs::
.. group-tab:: Composer-based installation
.. code-block:: bash
vendor/bin/typo3 redirects:cleanup -c 50 -a 30
.. group-tab:: Legacy installation
.. code-block:: bash
typo3/sysext/core/bin/typo3 redirects:cleanup -c 50 -a 30
.. hint::
The `-c` option does nothing if the
:ref:`hitcounter feature toggle <hit-counter>` is not enabled. Be careful
when using the `-c` (= `--hitCount`) option. It is advised to combine it with
`-a`, otherwise this will also cleanup redirects which were just created and
did not have the possibility to accumulate any hits.
**Example 2:** Clean all redirects for domains foo.com and bar.com older than 90 days
and with hit counter less than 100 which start with the source path `/foo/bar`
and have a status code of 302 or 303.
.. tabs::
.. group-tab:: Composer-based installation
.. code-block:: bash
vendor/bin/typo3 redirects:cleanup --domain foo.com --domain bar.com \
--age 90 --hitCount 100 --path "/foo/bar%" --statusCode 302 --statusCode 303
.. group-tab:: Legacy installation
.. code-block:: bash
typo3/sysext/core/bin/typo3 redirects:cleanup redirects:cleanup --domain foo.com --domain bar.com \
--age 90 --hitCount 100 --path "/foo/bar%" --statusCode 302 --statusCode 303
.. _redirects-checkintegrity:
redirects:checkintegrity
------------------------
The checkintegrity command checks existing redirects for conflicts. A typical
conflict may be a :ref:`redirect loop <redirect-loop>`. In this case the source
and target point to the same page or the redirect loop affects a number of
redirects, each redirecting to the next and looping back to the first, e.g.
`/a => b, /b => /a`.
.. warning::
Currently, there are known problems where the checkintegrity command
may report false positives. This can happen if additional routing enhancers
/ decorators are in place.
Example usage to check all sites:
.. tabs::
.. group-tab:: Composer-based installation
.. code-block:: bash
vendor/bin/typo3 redirects:checkintegrity
.. group-tab:: Legacy installation
.. code-block:: bash
typo3/sysext/core/bin/typo3 redirects:checkintegrity
Check only the site mysite:
.. tabs::
.. group-tab:: Composer-based installation
.. code-block:: bash
vendor/bin/typo3 redirects:checkintegrity mysite
.. group-tab:: Legacy installation
.. code-block:: bash
typo3/sysext/core/bin/typo3 redirects:checkintegrity mysite
This will output one line per redirect conflict. The output may look like
this:
.. code-block:: none
Redirect (Host: *, Path: /test-1) conflicts with http://mysite/test-1
You can now search for the affected redirects in the redirects module, e.g.
by filtering with *Source Path* `/test-1`.
.. _editor-permission:
Configure editor permission
===========================
By default, editors (without admin privileges) cannot access redirects directly and they cannot
revert automatic redirects. This can be problematic, because the notification
with the option to revert redirects and the notification that they were reverted
appears regardless, even if an editor does not have access and the redirects
are not reverted.
In order to make **reverting redirects** possible for non-admin backend users,
configure this in the backend group :guilabel:`Access Lists` tab:
- Activate :guilabel:`Redirect [sys_redirect]` in :guilabel:`Tables (listing)`
- Activate :guilabel:`Redirect [sys_redirect]` in :guilabel:`Tables (modify)`
In order to give editors full access to the **redirects module**, give them
access to the :sql:`sys_redirect` table as outlined above and configure this in
the backend group :guilabel:`Access Lists` tab:
- Activate :guilabel:`Link Management > Redirects [redirects]` in
:guilabel:`Modules`.
.. warning::
It is recommended to only give trusted and experienced backend users access
to the redirects module because they will have access to all redirects for
the entire installation and may unintentionally wreak havoc on the site.
Especially problematic can be redirect loops because they result in
broken pages, but these can be detected, using :ref:`redirects:checkintegrity
<redirects-checkintegrity>`.
By default the fields *Source Domain*, *Source Path* and *Target* are enabled,
the rest are excluded fields, which must be enabled for the respective backend user
group in the :guilabel:`Access Lists` tab > :guilabel:`Allowed excludefields` >
:guilabel:`Redirect`.
.. figure:: ../Images/RedirectAllowedExcludefields.png
:class: shadow
Allowed excludefields
.. _hit-counter:
Hit counter
===========
The hit counter can be activated via
:ref:`Feature Toggle <t3coreapi:feature-toggles>`, either in the backend in
:guilabel:`Settings` > :guilabel:`Feature Toggles` >
:guilabel:`Redirects: hit count` or in the
configuration file :file:`system/settings.php` or
:file:`system/additional.php`.
.. code-block:: php
'SYS' => [
'features' => [
'redirects.hitCount' => true
],
],
This feature toggle is disabled by default, because it comes with a small performance
impact that requires additional SQL :sql:`UPDATE` queries.
Every time a page is accessed the hit counter will be incremented. Based on the
hit counter, a delete policy for unnecessary redirects can be defined.
Visit the :ref:`redirects:cleanup <redirects-cleanup>` with the option
`-c` for more information.
.. _system-reports:
System reports
==============
The redirect conflicts will also be shown in the system report, available
via :guilabel:`Reports` > :guilabel:`Status Report` in the TYPO3 backend.
It is required to run `redirects:checkintegrity` regularly, so that the results
can be displayed in the report.
The information is stored in the registry (:sql:`sys_registry` table in the
database).
.. figure:: ../Images/SystemReportConflicts.png
:class: with-shadow
Redirect conflicts in system report
In case :ref:`redirects:checkintegrity <redirects-checkintegrity>` was not run
within the last 24 hours an additional informational status will appear in the
report:
.. attention::
List of conflicting redirects may not be up to date!
Regularly run the console command `redirects:checkintegrity`.
This can be configured in the extension configuration with these 2 settings:
* :ref:`showCheckIntegrityInfoInReports <extconf_showCheckIntegrityInfoInReports>`
* :ref:`showCheckIntegrityInfoInReportsSeconds <extconf_showCheckIntegrityInfoInReportsSeconds>`
.. _extconf:
Extension configuration
=======================
**Reports**
.. _extconf_showCheckIntegrityInfoInReports:
.. confval:: Show information in reports if checkintegrity was not run.
:Field: showCheckIntegrityInfoInReports
Show informational status in the reports if redirects:checkintegrity was
not run within the last 24 hours, or rather the number of seconds indicated
in the setting
:ref:`showCheckIntegrityInfoInReportsSeconds <extconf_showCheckIntegrityInfoInReportsSeconds>`.
.. _extconf_showCheckIntegrityInfoInReportsSeconds:
.. confval:: Number of seconds to consider last checkintegrity report.
:Field: showCheckIntegrityInfoInReportsSeconds
:Default: 86400 (is 24 hours in seconds)
Number of seconds which must pass until the informational message is shown
about checkintegrity in the reports.
+11
View File
@@ -0,0 +1,11 @@
:template: sitemap.html
.. include:: /Includes.rst.txt
.. _sitemap:
=======
Sitemap
=======
.. The sitemap.html template will insert here the page tree automatically.
+372
View File
@@ -0,0 +1,372 @@
.. include:: /Includes.rst.txt
.. _usage:
=====
Usage
=====
.. _usage-redirects-module:
Redirects module
================
Access the redirects module in the TYPO3 backend under :guilabel:`Sites > Redirects`.
.. figure:: ../Images/RedirectsMenu.png
:class: with-shadow
Open Redirects module
.. _usage-redirects-module-list:
List
----
.. figure:: ../Images/RedirectList.png
:class: with-shadow
Redirect list
You will see a list of the existing redirects with the following columns labels.
#. **Source Domain**
#. **Source Path**
#. **Target**
#. **Count**: Number of "hits" (only if hit counter is on)
#. **Last Hit on**: When was the most recent redirect "hit" (only if hit
counter is on)
#. *Action buttons*: View page, edit, disable and delete |action_buttons_image|
.. tip::
Hover over the text to see the link markup (underline) and a tooltip.
It is also possible to **sort** by clicking on the :guilabel:`Source Host` or
:guilabel:`Source Path` column headers and changing the sort order by clicking
again, as also done elsewhere in the backend.
By clicking on the *Source Path* of one of the columns or on the pencil edit
icon |edit_action_image|, you can **edit** the record. Clicking on a link in the
*Destination* column, should open the link target.
The :guilabel:`+` sign on the top will open an edit form to create a **new
redirect**.
It is also possible to **filter**, e.g. by the *Source Path*, *Status Code*,
*Creation type*, *Protected* or only show redirect records which were "*Never hit*"
(see Information on :ref:`Hit counter <hit-counter>` which must be explicitly
enabled via Feature Toggle).
.. _usage-redirects-module-edit-form:
Edit form
---------
When creating a new redirect or editing an existing one, the edit form will open.
A redirect generally consists of these 2 parts which are separated in the
edit form:
#. A **source** part (host, path, query parameters) which is matched against
the URL. If it matches, the redirect is applied
#. A **target** part which defines where the redirect should redirect to and
some additional parameters like the HTTP status code, whether to force HTTPS
and keep query parameters
Also, the redirect has some additional parameters that are specific for the
redirect record but not relevant when generating the redirect, such as the
:guilabel:`Protected` field.
Admin users will see the respective database fields from the table
:sql:`sys_redirect` in square brackets (e.g.
:guilabel:`Source Domain [source_host]`) next to the label if in debug mode.
Non-admin users may not see all the fields. By default *Source Domain*, *Source Path*
and *Target* are enabled, the rest are exclude fields and must be enabled in the
backend group permissions, see
:ref:`backend user configuration <editor-permission>`.
.. _usage-redirects-module-edit-form-general-tab:
General tab
~~~~~~~~~~~
.. figure:: ../Images/RedirectEdit.png
:class: with-shadow
Edit redirect
----
**Source:**
.. confval:: Source Domain
:Field: source_host
It is possible to select one of the domains from the site configuration or
use the wildcard (`*`). In this case the redirect applies to all sites!
.. confval:: Source Path
:Field: source_path
Can be an actual path, e.g. `/path`. For URLs with different entry points
for languages, you should use the full path, e.g. `/en/path`.
:ref:`Regular expressions <regex-examples>` are possible, but then
`is_regexp` must be enabled. Regular expressions must be enclosed in
delimiters, e.g. `#^/path/([a-zA-Z]{1}[a-zA-Z0-9_/-]+)#` or
`/^\/path\/([a-zA-Z]{1}[a-zA-Z0-9_/-]+)/`.
.. confval:: Respect GET Parameters
:Field: respect_query_parameters
If on, matching is also performed on query parameters. If off, matching is
only performed on the path.
.. confval:: Is regular expression?
:Field: is_regexp
Evaluate the Source Path as regular expression.
----
**Target:**
.. confval:: Target
:Field: target
The redirect target, can be a
- path, e.g. `/features`
- URL, e.g. `https://example.org/features`
- page ID or page URI, e.g. `t3://page?uid=1`
- file URI, e.g. `t3://file?uid=1`
- path with reference to
:ref:`regular expression capturing group <regex-examples>` if the regular
expression feature is used with e.g. capturing groups in Source Path, e.g.
`/newpath/$1`
.. confval:: Status Code HTTP Header
:Field: target_statuscode
The :ref:`HTTP status code <http-status-codes>` that will be sent to the
client. This is 307 (Temporary Redirect) by default.
.. confval:: Force SSL Redirect
:Field: force_https
When redirecting, use HTTPS when constructing the target URL. This will even
be the case, if a full URL is given as target (e.g.
`http://example.com/features`) or if the entry point of a site uses HTTP, so
make sure your site supports HTTPS (which is recommended anyway).
.. confval:: Keep GET Parameters
:Field: keep_query_parameters
When redirecting, add query parameters of original URL (with possible
changes) to the target. By default, the query parameters are omitted, so
source URL `https://example.com/features?abc=1` would be redirected to
`https://example.com/all-features`. If there are already query
parameters in the target field, these are used instead.
.. confval:: Protected
:Field: protected
This does not affect the redirect itself. It protects the record from
automatic deletion (e.g. with redirects:cleanup).
.. confval:: Creation Type
:Field: creation_type
This field allows to differentiate between redirects that are created
automatically when the slug of a page is changed and those that are created
in the backend module by editors.
.. confval:: Integrity Status
:Field: integrity_status
This field hints about a broken redirect, for example, if the page references
to itself.
.. _usage-redirects-module-edit-form-statistic-tab:
Statistics tab
~~~~~~~~~~~~~~
.. figure:: ../Images/RedirectsEditStatistics.png
:class: shadow
Statistics tab with hit counter
This tab is only available, if the hit counter is enabled. Here you can disable
the hit counter for a specific redirect and also see read-only statistics.
.. confval:: Hit Counter
:Field: disable_hitcount
Disable the hit counter only for this redirect.
.. confval:: Count
:Field: hitcount
:Editable: read only
Number of hits for this particular redirect. (How often was the page
accessed which triggered this redirect?)
.. confval:: Last Hit on
:Field: lasthiton
:Editable: read only
When was the last hit on this redirect?
.. confval:: Created At
:Field: createdon
:Editable: read only
When was this redirect created?
.. _usage-redirects-module-edit-form-access-tab:
Access tab
~~~~~~~~~~
.. confval:: Enabled
:Field: disabled
If disabled, the redirect has no effect.
.. confval:: Start
:Field: starttime
If this is not empty, "now" (current time) must be after Start time for the
redirect to have effect.
.. confval:: Stop
:Field: endtime
If this is not empty, "now" (current time) must be before Stop time for the
redirect to have effect.
.. _usage-redirects-module-edit-form-notes-tab:
Notes
~~~~~
.. confval:: Description
:Field: description
Add context to the corresponding redirect. The added information is also
displayed in the "Record information" info box above the edit form.
.. _regex-examples:
Regex examples
--------------
.. _regex-examples-regex:
Example 1: Source path with regular expression and capturing group
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
redirect
+-------------------------------------------------+-----------------------+----------------------------------------+
| Source Path | Is Regular Expression | target |
+-------------------------------------------------+-----------------------+----------------------------------------+
| `#^/path/([a-zA-Z]{1}[a-zA-Z0-9_/-]+)#` | true | :samp:`https://example.org/newpath/$1` |
+-------------------------------------------------+-----------------------+----------------------------------------+
with the following result:
+--------------------------------------------+-----------------------------------------------+
| URL | result URL |
+--------------------------------------------+-----------------------------------------------+
| :samp:`https://example.org/path/something` | :samp:`https://example.org/newpath/something` |
+--------------------------------------------+-----------------------------------------------+
.. _regex-examples-regex-relative:
Example 2: Source path with regular expression, capturing group and relative target
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
redirect
+-------------------------------------------------+-----------------------+---------------+
| Source Path | Is Regular Expression | target |
+-------------------------------------------------+-----------------------+---------------+
| `#^/another/path/([a-zA-Z]{1}[a-zA-Z0-9_/-]+)#` | true | `/newpath/$1` |
+-------------------------------------------------+-----------------------+---------------+
with the following result:
+----------------------------------------------------+--------------------------------------------------------+
| URL | result URL |
+----------------------------------------------------+--------------------------------------------------------+
| :samp:`https://example.org/another/path/something` | :samp:`https://example.org/relative/newpath/something` |
+----------------------------------------------------+--------------------------------------------------------+
Using a relative target is necessary if a redirect must work on multiple domains or multiple environments.
.. important::
TYPO3 will not syntax check the redirect. Make sure you enter working
redirects enclosed in delimiters. Use tools like https://regex101.com/,
if necessary.
.. _automatic-redirect-creation:
Automatic redirects creation
============================
Redirects are created automatically on slug changes, if EXT:redirects is
installed and automatic creation is enabled in
:ref:`site configuration <site-configuration>`.
A redirect from the old URL to the new URL will be created. All sub pages are
checked too and the slugs will be updated and redirects will be created for
these as well.
After the creation of the redirects a notification will be shown to the user.
.. figure:: ../Images/RedirectRevert.png
:class: with-shadow
Revert redirect
The notification contains two possible actions:
- revert the complete slug update and remove the redirects
- or only remove the redirects
.. note::
No redirects are generated for workspace versions in the TYPO3 backend.
The setting `redirect.autoCreateRedirects` is internally disabled in this
case.
.. |edit_action_image| image:: ../Images/EditAction.png
.. |action_buttons_image| image:: ../Images/RedirectActionButtons.png
+21
View File
@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="UTF-8"?>
<guides xmlns="https://www.phpdoc.org/guides" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="https://www.phpdoc.org/guides ../vendor/phpdocumentor/guides-cli/resources/schema/guides.xsd"
links-are-relative="true">
<extension class="\T3Docs\Typo3DocsTheme\DependencyInjection\Typo3DocsThemeExtension"
project-home="https://extensions.typo3.org/extension/redirects/"
project-contact="https://typo3.slack.com/archives/C025BQLFA"
project-repository="https://github.com/typo3/typo3"
project-issues="https://forge.typo3.org/projects/typo3cms-core/issues"
edit-on-github-branch="main"
edit-on-github="typo3/typo3"
edit-on-github-directory="typo3/sysext/redirects/Documentation/"
typo3-core-preferred="main"
interlink-shortcode="typo3/cms-redirects"
/>
<project title="Redirects"
release="main (development)"
version="main (development)"
copyright="since 2018 by the TYPO3 contributors"
/>
</guides>
+339
View File
@@ -0,0 +1,339 @@
GNU GENERAL PUBLIC LICENSE
Version 2, June 1991
Copyright (C) 1989, 1991 Free Software Foundation, Inc.,
51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The licenses for most software are designed to take away your
freedom to share and change it. By contrast, the GNU General Public
License is intended to guarantee your freedom to share and change free
software--to make sure the software is free for all its users. This
General Public License applies to most of the Free Software
Foundation's software and to any other program whose authors commit to
using it. (Some other Free Software Foundation software is covered by
the GNU Lesser General Public License instead.) You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
this service if you wish), that you receive source code or can get it
if you want it, that you can change the software or use pieces of it
in new free programs; and that you know you can do these things.
To protect your rights, we need to make restrictions that forbid
anyone to deny you these rights or to ask you to surrender the rights.
These restrictions translate to certain responsibilities for you if you
distribute copies of the software, or if you modify it.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must give the recipients all the rights that
you have. You must make sure that they, too, receive or can get the
source code. And you must show them these terms so they know their
rights.
We protect your rights with two steps: (1) copyright the software, and
(2) offer you this license which gives you legal permission to copy,
distribute and/or modify the software.
Also, for each author's protection and ours, we want to make certain
that everyone understands that there is no warranty for this free
software. If the software is modified by someone else and passed on, we
want its recipients to know that what they have is not the original, so
that any problems introduced by others will not reflect on the original
authors' reputations.
Finally, any free program is threatened constantly by software
patents. We wish to avoid the danger that redistributors of a free
program will individually obtain patent licenses, in effect making the
program proprietary. To prevent this, we have made it clear that any
patent must be licensed for everyone's free use or not licensed at all.
The precise terms and conditions for copying, distribution and
modification follow.
GNU GENERAL PUBLIC LICENSE
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
0. This License applies to any program or other work which contains
a notice placed by the copyright holder saying it may be distributed
under the terms of this General Public License. The "Program", below,
refers to any such program or work, and a "work based on the Program"
means either the Program or any derivative work under copyright law:
that is to say, a work containing the Program or a portion of it,
either verbatim or with modifications and/or translated into another
language. (Hereinafter, translation is included without limitation in
the term "modification".) Each licensee is addressed as "you".
Activities other than copying, distribution and modification are not
covered by this License; they are outside its scope. The act of
running the Program is not restricted, and the output from the Program
is covered only if its contents constitute a work based on the
Program (independent of having been made by running the Program).
Whether that is true depends on what the Program does.
1. You may copy and distribute verbatim copies of the Program's
source code as you receive it, in any medium, provided that you
conspicuously and appropriately publish on each copy an appropriate
copyright notice and disclaimer of warranty; keep intact all the
notices that refer to this License and to the absence of any warranty;
and give any other recipients of the Program a copy of this License
along with the Program.
You may charge a fee for the physical act of transferring a copy, and
you may at your option offer warranty protection in exchange for a fee.
2. You may modify your copy or copies of the Program or any portion
of it, thus forming a work based on the Program, and copy and
distribute such modifications or work under the terms of Section 1
above, provided that you also meet all of these conditions:
a) You must cause the modified files to carry prominent notices
stating that you changed the files and the date of any change.
b) You must cause any work that you distribute or publish, that in
whole or in part contains or is derived from the Program or any
part thereof, to be licensed as a whole at no charge to all third
parties under the terms of this License.
c) If the modified program normally reads commands interactively
when run, you must cause it, when started running for such
interactive use in the most ordinary way, to print or display an
announcement including an appropriate copyright notice and a
notice that there is no warranty (or else, saying that you provide
a warranty) and that users may redistribute the program under
these conditions, and telling the user how to view a copy of this
License. (Exception: if the Program itself is interactive but
does not normally print such an announcement, your work based on
the Program is not required to print an announcement.)
These requirements apply to the modified work as a whole. If
identifiable sections of that work are not derived from the Program,
and can be reasonably considered independent and separate works in
themselves, then this License, and its terms, do not apply to those
sections when you distribute them as separate works. But when you
distribute the same sections as part of a whole which is a work based
on the Program, the distribution of the whole must be on the terms of
this License, whose permissions for other licensees extend to the
entire whole, and thus to each and every part regardless of who wrote it.
Thus, it is not the intent of this section to claim rights or contest
your rights to work written entirely by you; rather, the intent is to
exercise the right to control the distribution of derivative or
collective works based on the Program.
In addition, mere aggregation of another work not based on the Program
with the Program (or with a work based on the Program) on a volume of
a storage or distribution medium does not bring the other work under
the scope of this License.
3. You may copy and distribute the Program (or a work based on it,
under Section 2) in object code or executable form under the terms of
Sections 1 and 2 above provided that you also do one of the following:
a) Accompany it with the complete corresponding machine-readable
source code, which must be distributed under the terms of Sections
1 and 2 above on a medium customarily used for software interchange; or,
b) Accompany it with a written offer, valid for at least three
years, to give any third party, for a charge no more than your
cost of physically performing source distribution, a complete
machine-readable copy of the corresponding source code, to be
distributed under the terms of Sections 1 and 2 above on a medium
customarily used for software interchange; or,
c) Accompany it with the information you received as to the offer
to distribute corresponding source code. (This alternative is
allowed only for noncommercial distribution and only if you
received the program in object code or executable form with such
an offer, in accord with Subsection b above.)
The source code for a work means the preferred form of the work for
making modifications to it. For an executable work, complete source
code means all the source code for all modules it contains, plus any
associated interface definition files, plus the scripts used to
control compilation and installation of the executable. However, as a
special exception, the source code distributed need not include
anything that is normally distributed (in either source or binary
form) with the major components (compiler, kernel, and so on) of the
operating system on which the executable runs, unless that component
itself accompanies the executable.
If distribution of executable or object code is made by offering
access to copy from a designated place, then offering equivalent
access to copy the source code from the same place counts as
distribution of the source code, even though third parties are not
compelled to copy the source along with the object code.
4. You may not copy, modify, sublicense, or distribute the Program
except as expressly provided under this License. Any attempt
otherwise to copy, modify, sublicense or distribute the Program is
void, and will automatically terminate your rights under this License.
However, parties who have received copies, or rights, from you under
this License will not have their licenses terminated so long as such
parties remain in full compliance.
5. You are not required to accept this License, since you have not
signed it. However, nothing else grants you permission to modify or
distribute the Program or its derivative works. These actions are
prohibited by law if you do not accept this License. Therefore, by
modifying or distributing the Program (or any work based on the
Program), you indicate your acceptance of this License to do so, and
all its terms and conditions for copying, distributing or modifying
the Program or works based on it.
6. Each time you redistribute the Program (or any work based on the
Program), the recipient automatically receives a license from the
original licensor to copy, distribute or modify the Program subject to
these terms and conditions. You may not impose any further
restrictions on the recipients' exercise of the rights granted herein.
You are not responsible for enforcing compliance by third parties to
this License.
7. If, as a consequence of a court judgment or allegation of patent
infringement or for any other reason (not limited to patent issues),
conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot
distribute so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you
may not distribute the Program at all. For example, if a patent
license would not permit royalty-free redistribution of the Program by
all those who receive copies directly or indirectly through you, then
the only way you could satisfy both it and this License would be to
refrain entirely from distribution of the Program.
If any portion of this section is held invalid or unenforceable under
any particular circumstance, the balance of the section is intended to
apply and the section as a whole is intended to apply in other
circumstances.
It is not the purpose of this section to induce you to infringe any
patents or other property right claims or to contest validity of any
such claims; this section has the sole purpose of protecting the
integrity of the free software distribution system, which is
implemented by public license practices. Many people have made
generous contributions to the wide range of software distributed
through that system in reliance on consistent application of that
system; it is up to the author/donor to decide if he or she is willing
to distribute software through any other system and a licensee cannot
impose that choice.
This section is intended to make thoroughly clear what is believed to
be a consequence of the rest of this License.
8. If the distribution and/or use of the Program is restricted in
certain countries either by patents or by copyrighted interfaces, the
original copyright holder who places the Program under this License
may add an explicit geographical distribution limitation excluding
those countries, so that distribution is permitted only in or among
countries not thus excluded. In such case, this License incorporates
the limitation as if written in the body of this License.
9. The Free Software Foundation may publish revised and/or new versions
of the General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the Program
specifies a version number of this License which applies to it and "any
later version", you have the option of following the terms and conditions
either of that version or of any later version published by the Free
Software Foundation. If the Program does not specify a version number of
this License, you may choose any version ever published by the Free Software
Foundation.
10. If you wish to incorporate parts of the Program into other free
programs whose distribution conditions are different, write to the author
to ask for permission. For software which is copyrighted by the Free
Software Foundation, write to the Free Software Foundation; we sometimes
make exceptions for this. Our decision will be guided by the two goals
of preserving the free status of all derivatives of our free software and
of promoting the sharing and reuse of software generally.
NO WARRANTY
11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY
FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN
OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES
PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED
OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS
TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE
PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING,
REPAIR OR CORRECTION.
12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR
REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,
INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING
OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED
TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY
YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER
PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE
POSSIBILITY OF SUCH DAMAGES.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
convey the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software; you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation; either version 2 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License along
with this program; if not, write to the Free Software Foundation, Inc.,
51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
Also add information on how to contact you by electronic and paper mail.
If the program is interactive, make it output a short notice like this
when it starts in an interactive mode:
Gnomovision version 69, Copyright (C) year name of author
Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, the commands you use may
be called something other than `show w' and `show c'; they could even be
mouse-clicks or menu items--whatever suits your program.
You should also get your employer (if you work as a programmer) or your
school, if any, to sign a "copyright disclaimer" for the program, if
necessary. Here is a sample; alter the names:
Yoyodyne, Inc., hereby disclaims all copyright interest in the program
`Gnomovision' (which makes passes at compilers) written by James Hacker.
<signature of Ty Coon>, 1 April 1989
Ty Coon, President of Vice
This General Public License does not permit incorporating your program into
proprietary programs. If your program is a subroutine library, you may
consider it more useful to permit linking proprietary applications with the
library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License.
+11
View File
@@ -0,0 +1,11 @@
=============================
TYPO3 extension ``redirects``
=============================
This extension makes it possible to create manual redirects, list existing
redirects and automatically create redirects on slug changes.
:Repository: https://github.com/typo3/typo3
:Issues: https://forge.typo3.org/
:Read online: https://docs.typo3.org/c/typo3/cms-redirects/main/en-us/
:Packagist: https://packagist.org/packages/typo3/cms-redirects

Some files were not shown because too many files have changed in this diff Show More