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