From 1a6eae9988f161393de081feddcb866edea87701 Mon Sep 17 00:00:00 2001 From: Sven Wappler Date: Mon, 10 Aug 2026 22:31:33 +0200 Subject: [PATCH] TYPO3 v15 dev-main snapshot () --- .gitignore | 1 + Classes/Command/CheckIntegrityCommand.php | 136 +++++ Classes/Command/CleanupRedirectsCommand.php | 125 +++++ .../CheckIntegrityConfiguration.php | 33 ++ Classes/Controller/ManagementController.php | 199 +++++++ Classes/Controller/QrCodeModuleController.php | 155 ++++++ .../RecordHistoryRollbackController.php | 123 +++++ .../ShortUrlGeneratorController.php | 94 ++++ .../Controller/ShortUrlModuleController.php | 156 ++++++ Classes/Data/SourceHostProvider.php | 105 ++++ Classes/Evaluation/SourceHost.php | 102 ++++ ...utoCreateRedirectHasBeenPersistedEvent.php | 53 ++ ...edirectIntegrityHaveBeenCollectedEvent.php | 47 ++ .../Event/BeforeRedirectMatchDomainEvent.php | 85 +++ ...ateRedirectRecordBeforePersistingEvent.php | 58 ++ ...irectManagementControllerViewDataEvent.php | 179 ++++++ Classes/Event/RedirectIntegrityCheckEvent.php | 141 +++++ Classes/Event/RedirectWasHitEvent.php | 70 +++ .../SlugRedirectChangeItemCreatedEvent.php | 46 ++ .../EventListener/AddPageTypeZeroSource.php | 137 +++++ .../AddPlainSlugReplacementSource.php | 64 +++ .../AddUrlsForSubPagesForIntegrityCheck.php | 102 ++++ .../AfterBackendPageRendererEventListener.php | 38 ++ Classes/EventListener/IncrementHitCount.php | 55 ++ .../QrCodeNewDocHeaderButton.php | 79 +++ .../RecordHistoryRollbackEventsListener.php | 41 ++ .../RedirectEditPermissionGuard.php | 44 ++ ...hortUrlNewDocHeaderButtonEventListener.php | 85 +++ Classes/Form/Element/QrCodeElement.php | 63 +++ .../Element/RenderCreationInformation.php | 84 +++ Classes/Form/Element/ShortUrlElement.php | 241 +++++++++ .../Form/FieldControl/ShortUrlGenerator.php | 57 ++ .../QrCodeSourceHostDataProvider.php | 41 ++ .../FormDataProvider/ShortUrlDataProvider.php | 41 ++ .../ValuePickerItemDataProvider.php | 66 +++ .../Hooks/DataHandlerCacheFlushingHook.php | 92 ++++ .../Hooks/DataHandlerPermissionGuardHook.php | 77 +++ Classes/Hooks/DataHandlerSlugUpdateHook.php | 109 ++++ Classes/Hooks/DispatchNotificationHook.php | 47 ++ Classes/Hooks/HandleNewQrCodeRecord.php | 51 ++ Classes/Hooks/HandleNewShortUrlRecord.php | 86 +++ Classes/Http/Middleware/RedirectHandler.php | 180 ++++++ Classes/Message/RedirectWasHitMessage.php | 57 ++ Classes/RedirectUpdate/PageTypeSource.php | 48 ++ .../PlainSlugReplacementRedirectSource.php | 46 ++ .../RedirectSourceCollection.php | 50 ++ .../RedirectSourceInterface.php | 29 + .../RedirectUpdate/SlugRedirectChangeItem.php | 101 ++++ .../SlugRedirectChangeItemFactory.php | 74 +++ Classes/Report/Status/RedirectStatus.php | 160 ++++++ Classes/Repository/Demand.php | 353 ++++++++++++ Classes/Repository/RedirectRepository.php | 496 +++++++++++++++++ Classes/Repository/StopQueryException.php | 24 + Classes/Security/RedirectPermissionGuard.php | 125 +++++ Classes/Service/IntegrityService.php | 200 +++++++ Classes/Service/ModulePaginationService.php | 57 ++ Classes/Service/RedirectCacheService.php | 130 +++++ Classes/Service/RedirectService.php | 511 ++++++++++++++++++ Classes/Service/ShortUrlService.php | 75 +++ Classes/Service/SlugService.php | 409 ++++++++++++++ .../TemporaryPermissionMutationService.php | 80 +++ .../HitCountDisplayCondition.php | 36 ++ Classes/Utility/RedirectConflict.php | 28 + .../TargetPageRecordViewHelper.php | 65 +++ Configuration/Backend/AjaxRoutes.php | 34 ++ Configuration/Backend/Modules.php | 57 ++ Configuration/Icons.php | 8 + Configuration/JavaScriptModules.php | 11 + Configuration/RequestMiddlewares.php | 18 + Configuration/Services.yaml | 20 + Configuration/Sets/redirects/config.yaml | 1 + Configuration/Sets/redirects/labels.xlf | 35 ++ .../Sets/redirects/settings.definitions.yaml | 20 + Configuration/TCA/Overrides/sys_redirect.php | 5 + Configuration/TCA/sys_redirect.php | 334 ++++++++++++ Documentation/Basics/Index.rst | 136 +++++ Documentation/BestPractices/Index.rst | 120 ++++ Documentation/Events/Index.rst | 75 +++ Documentation/Images/EditAction.png | Bin 0 -> 427 bytes Documentation/Images/InstallActivate.png | Bin 0 -> 20679 bytes .../Images/RedirectActionButtons.png | Bin 0 -> 1265 bytes .../Images/RedirectAllowedExcludefields.png | Bin 0 -> 29785 bytes Documentation/Images/RedirectDetective.png | Bin 0 -> 21032 bytes Documentation/Images/RedirectEdit.png | Bin 0 -> 35225 bytes Documentation/Images/RedirectList.png | Bin 0 -> 30192 bytes Documentation/Images/RedirectRevert.png | Bin 0 -> 16406 bytes .../Images/RedirectsEditStatistics.png | Bin 0 -> 12651 bytes Documentation/Images/RedirectsMenu.png | Bin 0 -> 4671 bytes .../Images/SystemReportConflicts.png | Bin 0 -> 25542 bytes Documentation/Includes.rst.txt | 1 + Documentation/Index.rst | 59 ++ Documentation/Installation/Index.rst | 56 ++ Documentation/Introduction/Index.rst | 71 +++ Documentation/KnownProblems/Index.rst | 25 + Documentation/Setup/Index.rst | 380 +++++++++++++ Documentation/Sitemap.rst | 11 + Documentation/Usage/Index.rst | 372 +++++++++++++ Documentation/guides.xml | 21 + LICENSE.txt | 339 ++++++++++++ README.rst | 11 + .../Private/Language/Modules/qrcodes.xlf | 47 ++ .../Private/Language/Modules/redirects.xlf | 17 + .../Private/Language/Modules/short_urls.xlf | 50 ++ Resources/Private/Language/locallang.xlf | 56 ++ Resources/Private/Language/locallang_db.xlf | 149 +++++ .../Private/Language/locallang_extconf.xlf | 14 + .../Language/locallang_module_redirect.xlf | 131 +++++ .../Private/Language/locallang_reports.xlf | 32 ++ .../Language/locallang_slug_service.xlf | 44 ++ .../Private/Partials/Pagination.fluid.html | 88 +++ .../Templates/Management/Overview.fluid.html | 499 +++++++++++++++++ .../Templates/QrCode/Overview.fluid.html | 403 ++++++++++++++ .../Report/RedirectStatus.fluid.html | 19 + .../Templates/ShortUrl/Overview.fluid.html | 451 ++++++++++++++++ Resources/Public/Icons/Extension.svg | 12 + .../Public/Icons/mimetypes-x-sys_redirect.svg | 1 + Resources/Public/JavaScript/event-handler.js | 13 + .../JavaScript/form-engine-evaluation.js | 13 + .../Public/JavaScript/redirects-module.js | 13 + .../Public/JavaScript/short-url-generator.js | 13 + composer.json | 66 +++ ext_conf_template.txt | 7 + ext_localconf.php | 86 +++ ext_tables.sql | 7 + 124 files changed, 11393 insertions(+) create mode 100644 .gitignore create mode 100644 Classes/Command/CheckIntegrityCommand.php create mode 100644 Classes/Command/CleanupRedirectsCommand.php create mode 100644 Classes/Configuration/CheckIntegrityConfiguration.php create mode 100644 Classes/Controller/ManagementController.php create mode 100644 Classes/Controller/QrCodeModuleController.php create mode 100644 Classes/Controller/RecordHistoryRollbackController.php create mode 100644 Classes/Controller/ShortUrlGeneratorController.php create mode 100644 Classes/Controller/ShortUrlModuleController.php create mode 100644 Classes/Data/SourceHostProvider.php create mode 100644 Classes/Evaluation/SourceHost.php create mode 100644 Classes/Event/AfterAutoCreateRedirectHasBeenPersistedEvent.php create mode 100644 Classes/Event/AfterPageUrlsForSiteForRedirectIntegrityHaveBeenCollectedEvent.php create mode 100644 Classes/Event/BeforeRedirectMatchDomainEvent.php create mode 100644 Classes/Event/ModifyAutoCreateRedirectRecordBeforePersistingEvent.php create mode 100644 Classes/Event/ModifyRedirectManagementControllerViewDataEvent.php create mode 100644 Classes/Event/RedirectIntegrityCheckEvent.php create mode 100644 Classes/Event/RedirectWasHitEvent.php create mode 100644 Classes/Event/SlugRedirectChangeItemCreatedEvent.php create mode 100644 Classes/EventListener/AddPageTypeZeroSource.php create mode 100644 Classes/EventListener/AddPlainSlugReplacementSource.php create mode 100644 Classes/EventListener/AddUrlsForSubPagesForIntegrityCheck.php create mode 100644 Classes/EventListener/AfterBackendPageRendererEventListener.php create mode 100644 Classes/EventListener/IncrementHitCount.php create mode 100644 Classes/EventListener/QrCodeNewDocHeaderButton.php create mode 100644 Classes/EventListener/RecordHistoryRollbackEventsListener.php create mode 100644 Classes/EventListener/RedirectEditPermissionGuard.php create mode 100644 Classes/EventListener/ShortUrlNewDocHeaderButtonEventListener.php create mode 100644 Classes/Form/Element/QrCodeElement.php create mode 100644 Classes/Form/Element/RenderCreationInformation.php create mode 100644 Classes/Form/Element/ShortUrlElement.php create mode 100644 Classes/Form/FieldControl/ShortUrlGenerator.php create mode 100644 Classes/FormDataProvider/QrCodeSourceHostDataProvider.php create mode 100644 Classes/FormDataProvider/ShortUrlDataProvider.php create mode 100644 Classes/FormDataProvider/ValuePickerItemDataProvider.php create mode 100644 Classes/Hooks/DataHandlerCacheFlushingHook.php create mode 100644 Classes/Hooks/DataHandlerPermissionGuardHook.php create mode 100644 Classes/Hooks/DataHandlerSlugUpdateHook.php create mode 100644 Classes/Hooks/DispatchNotificationHook.php create mode 100644 Classes/Hooks/HandleNewQrCodeRecord.php create mode 100644 Classes/Hooks/HandleNewShortUrlRecord.php create mode 100644 Classes/Http/Middleware/RedirectHandler.php create mode 100644 Classes/Message/RedirectWasHitMessage.php create mode 100644 Classes/RedirectUpdate/PageTypeSource.php create mode 100644 Classes/RedirectUpdate/PlainSlugReplacementRedirectSource.php create mode 100644 Classes/RedirectUpdate/RedirectSourceCollection.php create mode 100644 Classes/RedirectUpdate/RedirectSourceInterface.php create mode 100644 Classes/RedirectUpdate/SlugRedirectChangeItem.php create mode 100644 Classes/RedirectUpdate/SlugRedirectChangeItemFactory.php create mode 100644 Classes/Report/Status/RedirectStatus.php create mode 100644 Classes/Repository/Demand.php create mode 100644 Classes/Repository/RedirectRepository.php create mode 100644 Classes/Repository/StopQueryException.php create mode 100644 Classes/Security/RedirectPermissionGuard.php create mode 100644 Classes/Service/IntegrityService.php create mode 100644 Classes/Service/ModulePaginationService.php create mode 100644 Classes/Service/RedirectCacheService.php create mode 100644 Classes/Service/RedirectService.php create mode 100644 Classes/Service/ShortUrlService.php create mode 100644 Classes/Service/SlugService.php create mode 100644 Classes/Service/TemporaryPermissionMutationService.php create mode 100644 Classes/UserFunctions/HitCountDisplayCondition.php create mode 100644 Classes/Utility/RedirectConflict.php create mode 100644 Classes/ViewHelpers/TargetPageRecordViewHelper.php create mode 100644 Configuration/Backend/AjaxRoutes.php create mode 100644 Configuration/Backend/Modules.php create mode 100644 Configuration/Icons.php create mode 100644 Configuration/JavaScriptModules.php create mode 100644 Configuration/RequestMiddlewares.php create mode 100644 Configuration/Services.yaml create mode 100644 Configuration/Sets/redirects/config.yaml create mode 100644 Configuration/Sets/redirects/labels.xlf create mode 100644 Configuration/Sets/redirects/settings.definitions.yaml create mode 100644 Configuration/TCA/Overrides/sys_redirect.php create mode 100644 Configuration/TCA/sys_redirect.php create mode 100644 Documentation/Basics/Index.rst create mode 100644 Documentation/BestPractices/Index.rst create mode 100644 Documentation/Events/Index.rst create mode 100644 Documentation/Images/EditAction.png create mode 100644 Documentation/Images/InstallActivate.png create mode 100644 Documentation/Images/RedirectActionButtons.png create mode 100644 Documentation/Images/RedirectAllowedExcludefields.png create mode 100644 Documentation/Images/RedirectDetective.png create mode 100644 Documentation/Images/RedirectEdit.png create mode 100644 Documentation/Images/RedirectList.png create mode 100644 Documentation/Images/RedirectRevert.png create mode 100644 Documentation/Images/RedirectsEditStatistics.png create mode 100644 Documentation/Images/RedirectsMenu.png create mode 100644 Documentation/Images/SystemReportConflicts.png create mode 100644 Documentation/Includes.rst.txt create mode 100644 Documentation/Index.rst create mode 100644 Documentation/Installation/Index.rst create mode 100644 Documentation/Introduction/Index.rst create mode 100644 Documentation/KnownProblems/Index.rst create mode 100644 Documentation/Setup/Index.rst create mode 100644 Documentation/Sitemap.rst create mode 100644 Documentation/Usage/Index.rst create mode 100644 Documentation/guides.xml create mode 100644 LICENSE.txt create mode 100644 README.rst create mode 100644 Resources/Private/Language/Modules/qrcodes.xlf create mode 100644 Resources/Private/Language/Modules/redirects.xlf create mode 100644 Resources/Private/Language/Modules/short_urls.xlf create mode 100644 Resources/Private/Language/locallang.xlf create mode 100644 Resources/Private/Language/locallang_db.xlf create mode 100644 Resources/Private/Language/locallang_extconf.xlf create mode 100644 Resources/Private/Language/locallang_module_redirect.xlf create mode 100644 Resources/Private/Language/locallang_reports.xlf create mode 100644 Resources/Private/Language/locallang_slug_service.xlf create mode 100644 Resources/Private/Partials/Pagination.fluid.html create mode 100644 Resources/Private/Templates/Management/Overview.fluid.html create mode 100644 Resources/Private/Templates/QrCode/Overview.fluid.html create mode 100644 Resources/Private/Templates/Report/RedirectStatus.fluid.html create mode 100644 Resources/Private/Templates/ShortUrl/Overview.fluid.html create mode 100644 Resources/Public/Icons/Extension.svg create mode 100644 Resources/Public/Icons/mimetypes-x-sys_redirect.svg create mode 100644 Resources/Public/JavaScript/event-handler.js create mode 100644 Resources/Public/JavaScript/form-engine-evaluation.js create mode 100644 Resources/Public/JavaScript/redirects-module.js create mode 100644 Resources/Public/JavaScript/short-url-generator.js create mode 100644 composer.json create mode 100644 ext_conf_template.txt create mode 100644 ext_localconf.php create mode 100644 ext_tables.sql diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..57872d0 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/vendor/ diff --git a/Classes/Command/CheckIntegrityCommand.php b/Classes/Command/CheckIntegrityCommand.php new file mode 100644 index 0000000..6129a53 --- /dev/null +++ b/Classes/Command/CheckIntegrityCommand.php @@ -0,0 +1,136 @@ +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; + } +} diff --git a/Classes/Command/CleanupRedirectsCommand.php b/Classes/Command/CleanupRedirectsCommand.php new file mode 100644 index 0000000..df51a7c --- /dev/null +++ b/Classes/Command/CleanupRedirectsCommand.php @@ -0,0 +1,125 @@ +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; + } +} diff --git a/Classes/Configuration/CheckIntegrityConfiguration.php b/Classes/Configuration/CheckIntegrityConfiguration.php new file mode 100644 index 0000000..ebafe81 --- /dev/null +++ b/Classes/Configuration/CheckIntegrityConfiguration.php @@ -0,0 +1,33 @@ +showInfoInReports = (bool)$extensionConfiguration['showCheckIntegrityInfoInReports']; + $this->seconds = (int)$extensionConfiguration['showCheckIntegrityInfoInReportsSeconds']; + } +} diff --git a/Classes/Controller/ManagementController.php b/Classes/Controller/ManagementController.php new file mode 100644 index 0000000..2f3e2a1 --- /dev/null +++ b/Classes/Controller/ManagementController.php @@ -0,0 +1,199 @@ +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']; + } +} diff --git a/Classes/Controller/QrCodeModuleController.php b/Classes/Controller/QrCodeModuleController.php new file mode 100644 index 0000000..fa6e47f --- /dev/null +++ b/Classes/Controller/QrCodeModuleController.php @@ -0,0 +1,155 @@ +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']; + } +} diff --git a/Classes/Controller/RecordHistoryRollbackController.php b/Classes/Controller/RecordHistoryRollbackController.php new file mode 100644 index 0000000..5c5016c --- /dev/null +++ b/Classes/Controller/RecordHistoryRollbackController.php @@ -0,0 +1,123 @@ +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']; + } +} diff --git a/Classes/Controller/ShortUrlGeneratorController.php b/Classes/Controller/ShortUrlGeneratorController.php new file mode 100644 index 0000000..78aa958 --- /dev/null +++ b/Classes/Controller/ShortUrlGeneratorController.php @@ -0,0 +1,94 @@ +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))); + } +} diff --git a/Classes/Controller/ShortUrlModuleController.php b/Classes/Controller/ShortUrlModuleController.php new file mode 100644 index 0000000..96bf5bd --- /dev/null +++ b/Classes/Controller/ShortUrlModuleController.php @@ -0,0 +1,156 @@ +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']; + } +} diff --git a/Classes/Data/SourceHostProvider.php b/Classes/Data/SourceHostProvider.php new file mode 100644 index 0000000..44fe9da --- /dev/null +++ b/Classes/Data/SourceHostProvider.php @@ -0,0 +1,105 @@ + + */ + 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 + */ + 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']; + } +} diff --git a/Classes/Evaluation/SourceHost.php b/Classes/Evaluation/SourceHost.php new file mode 100644 index 0000000..c679a15 --- /dev/null +++ b/Classes/Evaluation/SourceHost.php @@ -0,0 +1,102 @@ +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; + } +} diff --git a/Classes/Event/AfterAutoCreateRedirectHasBeenPersistedEvent.php b/Classes/Event/AfterAutoCreateRedirectHasBeenPersistedEvent.php new file mode 100644 index 0000000..b3d1ca1 --- /dev/null +++ b/Classes/Event/AfterAutoCreateRedirectHasBeenPersistedEvent.php @@ -0,0 +1,53 @@ +slugRedirectChangeItem; + } + + public function getSource(): RedirectSourceInterface + { + return $this->source; + } + + public function getRedirectRecord(): array + { + return $this->redirectRecord; + } +} diff --git a/Classes/Event/AfterPageUrlsForSiteForRedirectIntegrityHaveBeenCollectedEvent.php b/Classes/Event/AfterPageUrlsForSiteForRedirectIntegrityHaveBeenCollectedEvent.php new file mode 100644 index 0000000..50c70f1 --- /dev/null +++ b/Classes/Event/AfterPageUrlsForSiteForRedirectIntegrityHaveBeenCollectedEvent.php @@ -0,0 +1,47 @@ +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; + } +} diff --git a/Classes/Event/BeforeRedirectMatchDomainEvent.php b/Classes/Event/BeforeRedirectMatchDomainEvent.php new file mode 100644 index 0000000..3fdb472 --- /dev/null +++ b/Classes/Event/BeforeRedirectMatchDomainEvent.php @@ -0,0 +1,85 @@ +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; + } +} diff --git a/Classes/Event/ModifyAutoCreateRedirectRecordBeforePersistingEvent.php b/Classes/Event/ModifyAutoCreateRedirectRecordBeforePersistingEvent.php new file mode 100644 index 0000000..5398768 --- /dev/null +++ b/Classes/Event/ModifyAutoCreateRedirectRecordBeforePersistingEvent.php @@ -0,0 +1,58 @@ +slugRedirectChangeItem; + } + + public function getSource(): RedirectSourceInterface + { + return $this->source; + } + + public function getRedirectRecord(): array + { + return $this->redirectRecord; + } + + public function setRedirectRecord(array $redirectRecord): void + { + $this->redirectRecord = $redirectRecord; + } +} diff --git a/Classes/Event/ModifyRedirectManagementControllerViewDataEvent.php b/Classes/Event/ModifyRedirectManagementControllerViewDataEvent.php new file mode 100644 index 0000000..474a2a5 --- /dev/null +++ b/Classes/Event/ModifyRedirectManagementControllerViewDataEvent.php @@ -0,0 +1,179 @@ +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; + } +} diff --git a/Classes/Event/RedirectIntegrityCheckEvent.php b/Classes/Event/RedirectIntegrityCheckEvent.php new file mode 100644 index 0000000..7a9b85a --- /dev/null +++ b/Classes/Event/RedirectIntegrityCheckEvent.php @@ -0,0 +1,141 @@ +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 $redirect + */ + public function __construct( + private readonly array $redirect, + ) {} + + /** + * @return array + */ + 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; + } +} diff --git a/Classes/Event/RedirectWasHitEvent.php b/Classes/Event/RedirectWasHitEvent.php new file mode 100644 index 0000000..57fca6b --- /dev/null +++ b/Classes/Event/RedirectWasHitEvent.php @@ -0,0 +1,70 @@ +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; + } +} diff --git a/Classes/Event/SlugRedirectChangeItemCreatedEvent.php b/Classes/Event/SlugRedirectChangeItemCreatedEvent.php new file mode 100644 index 0000000..abc7741 --- /dev/null +++ b/Classes/Event/SlugRedirectChangeItemCreatedEvent.php @@ -0,0 +1,46 @@ +slugRedirectChangeItem; + } + + public function setSlugRedirectChangeItem(SlugRedirectChangeItem $slugRedirectChangeItem): void + { + $this->slugRedirectChangeItem = $slugRedirectChangeItem; + } +} diff --git a/Classes/EventListener/AddPageTypeZeroSource.php b/Classes/EventListener/AddPageTypeZeroSource.php new file mode 100644 index 0000000..1278897 --- /dev/null +++ b/Classes/EventListener/AddPageTypeZeroSource.php @@ -0,0 +1,137 @@ +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; + } +} diff --git a/Classes/EventListener/AddPlainSlugReplacementSource.php b/Classes/EventListener/AddPlainSlugReplacementSource.php new file mode 100644 index 0000000..c7030ca --- /dev/null +++ b/Classes/EventListener/AddPlainSlugReplacementSource.php @@ -0,0 +1,64 @@ +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); + } +} diff --git a/Classes/EventListener/AddUrlsForSubPagesForIntegrityCheck.php b/Classes/EventListener/AddUrlsForSubPagesForIntegrityCheck.php new file mode 100644 index 0000000..17c86da --- /dev/null +++ b/Classes/EventListener/AddUrlsForSubPagesForIntegrityCheck.php @@ -0,0 +1,102 @@ +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); + } +} diff --git a/Classes/EventListener/AfterBackendPageRendererEventListener.php b/Classes/EventListener/AfterBackendPageRendererEventListener.php new file mode 100644 index 0000000..41d4e34 --- /dev/null +++ b/Classes/EventListener/AfterBackendPageRendererEventListener.php @@ -0,0 +1,38 @@ +pageRenderer->loadJavaScriptModule('@typo3/redirects/event-handler.js'); + } +} diff --git a/Classes/EventListener/IncrementHitCount.php b/Classes/EventListener/IncrementHitCount.php new file mode 100644 index 0000000..ef4f2bd --- /dev/null +++ b/Classes/EventListener/IncrementHitCount.php @@ -0,0 +1,55 @@ +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(); + } +} diff --git a/Classes/EventListener/QrCodeNewDocHeaderButton.php b/Classes/EventListener/QrCodeNewDocHeaderButton.php new file mode 100644 index 0000000..5c2814e --- /dev/null +++ b/Classes/EventListener/QrCodeNewDocHeaderButton.php @@ -0,0 +1,79 @@ +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']; + } +} diff --git a/Classes/EventListener/RecordHistoryRollbackEventsListener.php b/Classes/EventListener/RecordHistoryRollbackEventsListener.php new file mode 100644 index 0000000..5dd3244 --- /dev/null +++ b/Classes/EventListener/RecordHistoryRollbackEventsListener.php @@ -0,0 +1,41 @@ +getTableName() !== 'sys_redirect' || $event->getCommand() === 'new') { + return; + } + + if (!$this->redirectPermissionGuard->isAllowedRedirect($event->getDatabaseRow())) { + $event->denyUserAccess(); + } + } +} diff --git a/Classes/EventListener/ShortUrlNewDocHeaderButtonEventListener.php b/Classes/EventListener/ShortUrlNewDocHeaderButtonEventListener.php new file mode 100644 index 0000000..5d87309 --- /dev/null +++ b/Classes/EventListener/ShortUrlNewDocHeaderButtonEventListener.php @@ -0,0 +1,85 @@ +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']; + } +} diff --git a/Classes/Form/Element/QrCodeElement.php b/Classes/Form/Element/QrCodeElement.php new file mode 100644 index 0000000..cd4b575 --- /dev/null +++ b/Classes/Form/Element/QrCodeElement.php @@ -0,0 +1,63 @@ +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'] = ' +
+ + ' . htmlspecialchars($languageService->translate('sys_redirect.redirect_type.qr_code', 'redirects.db')) . ' + +
+
+
+ +
+
+
+
+ '; + } else { + $resultArray['html'] = '
' . htmlspecialchars($languageService->translate('no_qrcode', 'redirects.messages')) . '
'; + } + + return $resultArray; + } +} diff --git a/Classes/Form/Element/RenderCreationInformation.php b/Classes/Form/Element/RenderCreationInformation.php new file mode 100644 index 0000000..6ecb079 --- /dev/null +++ b/Classes/Form/Element/RenderCreationInformation.php @@ -0,0 +1,84 @@ +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 = '' . htmlspecialchars($realName) . ' (' . htmlspecialchars($userName) . ')'; + } elseif ($userName !== '') { + $userHtml = '' . htmlspecialchars($userName) . ''; + } elseif ($userId > 0) { + $userHtml = '' . htmlspecialchars($this->getLanguageService()->sL('LLL:EXT:backend/Resources/Private/Language/locallang_show_rechis.xlf:userNotFound')) . ''; + } else { + $userHtml = '' . htmlspecialchars($this->getLanguageService()->sL('LLL:EXT:redirects/Resources/Private/Language/locallang.xlf:not_tracked')) . ''; + } + + $html = []; + $html[] = ''; + $html[] = htmlspecialchars($this->data['parameterArray']['fieldConf']['label'] ?? ''); + $html[] = ''; + $html[] = '
'; + $html[] = '
'; + $html[] = '
'; + $html[] = '
'; + $html[] = '
'; + $html[] = $avatarHtml; + $html[] = '
'; + $html[] = '

' . $userHtml . '

'; + if ($timestamp > 0) { + $html[] = htmlspecialchars($this->getLanguageService()->sL('LLL:EXT:redirects/Resources/Private/Language/locallang.xlf:created_on')) . ' '; + $html[] = htmlspecialchars(BackendUtility::datetime($timestamp)); + } + $html[] = '
'; + $html[] = '
'; + $html[] = '
'; + $html[] = '
'; + $html[] = '
'; + $html[] = '
'; + $resultArray['html'] = implode(LF, $html); + + return $resultArray; + } +} diff --git a/Classes/Form/Element/ShortUrlElement.php b/Classes/Form/Element/ShortUrlElement.php new file mode 100644 index 0000000..fd2e692 --- /dev/null +++ b/Classes/Form/Element/ShortUrlElement.php @@ -0,0 +1,241 @@ +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 . ' +
+ ' . $fieldInformationResult['html'] . ' + ' . $html . ' +
'; + + 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[] = '
'; + $html[] = '
'; + $html[] = '
'; + $html[] = '
'; + $html[] = ' ' . htmlspecialchars($completeUrl) . ''; + $html[] = ' '; + $html[] = ' ' . $this->iconFactory->getIcon('actions-clipboard', IconSize::SMALL)->render(); + $html[] = ' '; + $html[] = '
'; + $html[] = '
'; + $html[] = '
'; + $html[] = '
'; + + 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[] = '
'; + $html[] = '
'; + $html[] = '
'; + $html[] = '
'; + $html[] = ' ' . $sourceHostHtml; + $html[] = ' ' . $sourcePathHtml; + $html[] = ' ' . $fieldControlResult['html']; + $html[] = '
'; + $html[] = '
'; + $html[] = '
'; + $html[] = '
'; + + 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 = ''; + $html .= ''; + + // 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 .= ''; + $html .= htmlspecialchars($this->getLanguageService()->sL($itemLabel)); + $html .= ''; + } + } + + $html .= ''; + + 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 ''; + } +} diff --git a/Classes/Form/FieldControl/ShortUrlGenerator.php b/Classes/Form/FieldControl/ShortUrlGenerator.php new file mode 100644 index 0000000..4e39d8c --- /dev/null +++ b/Classes/Form/FieldControl/ShortUrlGenerator.php @@ -0,0 +1,57 @@ +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), + ], + ]; + } +} diff --git a/Classes/FormDataProvider/QrCodeSourceHostDataProvider.php b/Classes/FormDataProvider/QrCodeSourceHostDataProvider.php new file mode 100644 index 0000000..3b5cf4d --- /dev/null +++ b/Classes/FormDataProvider/QrCodeSourceHostDataProvider.php @@ -0,0 +1,41 @@ + '*', + 'value' => '*', + ]; + } + + $domains = $this->sourceHostProvider->getHosts(); + foreach ($domains as $domain) { + $result['processedTca']['columns']['source_host']['config']['valuePicker']['items'][] + = [ + 'label' => $domain, + 'value' => $domain, + ]; + } + } + return $result; + } +} diff --git a/Classes/Hooks/DataHandlerCacheFlushingHook.php b/Classes/Hooks/DataHandlerCacheFlushingHook.php new file mode 100644 index 0000000..5e1a92e --- /dev/null +++ b/Classes/Hooks/DataHandlerCacheFlushingHook.php @@ -0,0 +1,92 @@ +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(); + } +} diff --git a/Classes/Hooks/DataHandlerPermissionGuardHook.php b/Classes/Hooks/DataHandlerPermissionGuardHook.php new file mode 100644 index 0000000..fe3d8e8 --- /dev/null +++ b/Classes/Hooks/DataHandlerPermissionGuardHook.php @@ -0,0 +1,77 @@ +|null $incomingFieldArray + * @param-out array|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', + ); + } + } + } +} diff --git a/Classes/Hooks/DataHandlerSlugUpdateHook.php b/Classes/Hooks/DataHandlerSlugUpdateHook.php new file mode 100644 index 0000000..81885b5 --- /dev/null +++ b/Classes/Hooks/DataHandlerSlugUpdateHook.php @@ -0,0 +1,109 @@ + SlugRedirectChangeItem( $original = ['slug' => 'slug-a'] ), 14 => SlugRedirectChangeItem( $original = ['slug' => 'slug-x/example'] )` + * + * @var array + */ + 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); + } +} diff --git a/Classes/Hooks/DispatchNotificationHook.php b/Classes/Hooks/DispatchNotificationHook.php new file mode 100644 index 0000000..9f6d408 --- /dev/null +++ b/Classes/Hooks/DispatchNotificationHook.php @@ -0,0 +1,47 @@ +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` + } +} diff --git a/Classes/Hooks/HandleNewQrCodeRecord.php b/Classes/Hooks/HandleNewQrCodeRecord.php new file mode 100644 index 0000000..7bfbe0d --- /dev/null +++ b/Classes/Hooks/HandleNewQrCodeRecord.php @@ -0,0 +1,51 @@ +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']; + } +} diff --git a/Classes/Http/Middleware/RedirectHandler.php b/Classes/Http/Middleware/RedirectHandler.php new file mode 100644 index 0000000..a235dca --- /dev/null +++ b/Classes/Http/Middleware/RedirectHandler.php @@ -0,0 +1,180 @@ +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 === ''; + } +} diff --git a/Classes/Message/RedirectWasHitMessage.php b/Classes/Message/RedirectWasHitMessage.php new file mode 100644 index 0000000..ccc922b --- /dev/null +++ b/Classes/Message/RedirectWasHitMessage.php @@ -0,0 +1,57 @@ +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, + ]; + } +} diff --git a/Classes/RedirectUpdate/PageTypeSource.php b/Classes/RedirectUpdate/PageTypeSource.php new file mode 100644 index 0000000..59daf8e --- /dev/null +++ b/Classes/RedirectUpdate/PageTypeSource.php @@ -0,0 +1,48 @@ +host; + } + + public function getPath(): string + { + return $this->path; + } + + public function getPageType(): int + { + return $this->pageType; + } + + public function getTargetLinkParameters(): array + { + return $this->targetLinkParameters; + } +} diff --git a/Classes/RedirectUpdate/PlainSlugReplacementRedirectSource.php b/Classes/RedirectUpdate/PlainSlugReplacementRedirectSource.php new file mode 100644 index 0000000..ef326ce --- /dev/null +++ b/Classes/RedirectUpdate/PlainSlugReplacementRedirectSource.php @@ -0,0 +1,46 @@ +host; + } + + public function getPath(): string + { + return $this->path; + } + + public function getTargetLinkParameters(): array + { + return $this->targetLinkParameters; + } +} diff --git a/Classes/RedirectUpdate/RedirectSourceCollection.php b/Classes/RedirectUpdate/RedirectSourceCollection.php new file mode 100644 index 0000000..ce2af68 --- /dev/null +++ b/Classes/RedirectUpdate/RedirectSourceCollection.php @@ -0,0 +1,50 @@ + + */ + 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 + */ + public function all(): array + { + return $this->sources; + } + + public function count(): int + { + return $this->count; + } +} diff --git a/Classes/RedirectUpdate/RedirectSourceInterface.php b/Classes/RedirectUpdate/RedirectSourceInterface.php new file mode 100644 index 0000000..33f60e1 --- /dev/null +++ b/Classes/RedirectUpdate/RedirectSourceInterface.php @@ -0,0 +1,29 @@ +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, + ); + } +} diff --git a/Classes/RedirectUpdate/SlugRedirectChangeItemFactory.php b/Classes/RedirectUpdate/SlugRedirectChangeItemFactory.php new file mode 100644 index 0000000..a796e8f --- /dev/null +++ b/Classes/RedirectUpdate/SlugRedirectChangeItemFactory.php @@ -0,0 +1,74 @@ + 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(); + } +} diff --git a/Classes/Report/Status/RedirectStatus.php b/Classes/Report/Status/RedirectStatus.php new file mode 100644 index 0000000..3448c01 --- /dev/null +++ b/Classes/Report/Status/RedirectStatus.php @@ -0,0 +1,160 @@ +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']; + } +} diff --git a/Classes/Repository/Demand.php b/Classes/Repository/Demand.php new file mode 100644 index 0000000..d2b6c34 --- /dev/null +++ b/Classes/Repository/Demand.php @@ -0,0 +1,353 @@ +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; + } +} diff --git a/Classes/Repository/RedirectRepository.php b/Classes/Repository/RedirectRepository.php new file mode 100644 index 0000000..9db30e0 --- /dev/null +++ b/Classes/Repository/RedirectRepository.php @@ -0,0 +1,496 @@ +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> + */ + 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 $redirects + * @return list + */ + protected function sortOutInaccessibleRedirects(array $redirects): array + { + return array_filter($redirects, $this->redirectPermissionGuard->isAllowedRedirect(...)); + } +} diff --git a/Classes/Repository/StopQueryException.php b/Classes/Repository/StopQueryException.php new file mode 100644 index 0000000..ea69e64 --- /dev/null +++ b/Classes/Repository/StopQueryException.php @@ -0,0 +1,24 @@ +|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']; + } +} diff --git a/Classes/Service/IntegrityService.php b/Classes/Service/IntegrityService.php new file mode 100644 index 0000000..8e0c0fd --- /dev/null +++ b/Classes/Service/IntegrityService.php @@ -0,0 +1,200 @@ +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); + } +} diff --git a/Classes/Service/ModulePaginationService.php b/Classes/Service/ModulePaginationService.php new file mode 100644 index 0000000..4b5e4cc --- /dev/null +++ b/Classes/Service/ModulePaginationService.php @@ -0,0 +1,57 @@ +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; + } +} diff --git a/Classes/Service/RedirectCacheService.php b/Classes/Service/RedirectCacheService.php new file mode 100644 index 0000000..51bb2d0 --- /dev/null +++ b/Classes/Service/RedirectCacheService.php @@ -0,0 +1,130 @@ +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); + } +} diff --git a/Classes/Service/RedirectService.php b/Classes/Service/RedirectService.php new file mode 100644 index 0000000..f377b1c --- /dev/null +++ b/Classes/Service/RedirectService.php @@ -0,0 +1,511 @@ +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'); + } +} diff --git a/Classes/Service/ShortUrlService.php b/Classes/Service/ShortUrlService.php new file mode 100644 index 0000000..fdee959 --- /dev/null +++ b/Classes/Service/ShortUrlService.php @@ -0,0 +1,75 @@ +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; + } +} diff --git a/Classes/Service/SlugService.php b/Classes/Service/SlugService.php new file mode 100644 index 0000000..7e23ff1 --- /dev/null +++ b/Classes/Service/SlugService.php @@ -0,0 +1,409 @@ +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 + * @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; + } +} diff --git a/Classes/Service/TemporaryPermissionMutationService.php b/Classes/Service/TemporaryPermissionMutationService.php new file mode 100644 index 0000000..d9da0e2 --- /dev/null +++ b/Classes/Service/TemporaryPermissionMutationService.php @@ -0,0 +1,80 @@ +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'); + } +} diff --git a/Classes/UserFunctions/HitCountDisplayCondition.php b/Classes/UserFunctions/HitCountDisplayCondition.php new file mode 100644 index 0000000..61b6b44 --- /dev/null +++ b/Classes/UserFunctions/HitCountDisplayCondition.php @@ -0,0 +1,36 @@ +isFeatureEnabled('redirects.hitCount'); + } +} diff --git a/Classes/Utility/RedirectConflict.php b/Classes/Utility/RedirectConflict.php new file mode 100644 index 0000000..fbd34f6 --- /dev/null +++ b/Classes/Utility/RedirectConflict.php @@ -0,0 +1,28 @@ + + * ``` + * + * @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 []; + } + } +} diff --git a/Configuration/Backend/AjaxRoutes.php b/Configuration/Backend/AjaxRoutes.php new file mode 100644 index 0000000..31a2279 --- /dev/null +++ b/Configuration/Backend/AjaxRoutes.php @@ -0,0 +1,34 @@ + [ + 'path' => '/redirects/revert/correlation', + 'methods' => ['POST'], + 'target' => Controller\RecordHistoryRollbackController::class . '::revertCorrelation', + ], + + // Endpoint to generate a Short URL + 'short_url_generate' => [ + 'path' => '/short-url/generate', + 'methods' => ['POST'], + 'target' => Controller\ShortUrlGeneratorController::class . '::generate', + ], + + // Endpoint to validate a Short URL for uniqueness + 'short_url_validate' => [ + 'path' => '/short-url/validate', + 'methods' => ['POST'], + 'target' => Controller\ShortUrlGeneratorController::class . '::validate', + ], +]; diff --git a/Configuration/Backend/Modules.php b/Configuration/Backend/Modules.php new file mode 100644 index 0000000..556ad4c --- /dev/null +++ b/Configuration/Backend/Modules.php @@ -0,0 +1,57 @@ + [ + 'parent' => 'link_management', + 'access' => 'user', + 'path' => '/module/link-management/redirects', + 'iconIdentifier' => 'module-redirects', + 'labels' => 'redirects.modules.redirects', + 'aliases' => ['site_redirects'], + 'routes' => [ + '_default' => [ + 'target' => ManagementController::class . '::handleRequest', + ], + ], + 'moduleData' => [ + 'redirectType' => 'default', + ], + ], + 'qrcodes' => [ + 'parent' => 'link_management', + 'access' => 'user', + 'path' => '/module/link-management/qrcodes', + 'iconIdentifier' => 'module-qrcode', + 'labels' => 'redirects.modules.qrcodes', + 'routes' => [ + '_default' => [ + 'target' => QrCodeModuleController::class . '::handleRequest', + ], + ], + 'moduleData' => [ + 'redirectType' => 'qrcode', + ], + ], + 'short_urls' => [ + 'parent' => 'link_management', + 'access' => 'user', + 'path' => '/module/link-management/short-urls', + 'iconIdentifier' => 'module-urls', + 'labels' => 'redirects.modules.short_urls', + 'routes' => [ + '_default' => [ + 'target' => ShortUrlModuleController::class . '::handleRequest', + ], + ], + 'moduleData' => [ + 'redirectType' => 'short_url', + ], + ], +]; diff --git a/Configuration/Icons.php b/Configuration/Icons.php new file mode 100644 index 0000000..da6fddf --- /dev/null +++ b/Configuration/Icons.php @@ -0,0 +1,8 @@ + [ + 'provider' => \TYPO3\CMS\Core\Imaging\IconProvider\SvgIconProvider::class, + 'source' => 'EXT:redirects/Resources/Public/Icons/mimetypes-x-sys_redirect.svg', + ], +]; diff --git a/Configuration/JavaScriptModules.php b/Configuration/JavaScriptModules.php new file mode 100644 index 0000000..d7822d1 --- /dev/null +++ b/Configuration/JavaScriptModules.php @@ -0,0 +1,11 @@ + [ + 'backend', + 'core', + ], + 'imports' => [ + '@typo3/redirects/' => 'EXT:redirects/Resources/Public/JavaScript/', + ], +]; diff --git a/Configuration/RequestMiddlewares.php b/Configuration/RequestMiddlewares.php new file mode 100644 index 0000000..86e8a8d --- /dev/null +++ b/Configuration/RequestMiddlewares.php @@ -0,0 +1,18 @@ + [ + 'typo3/cms-redirects/redirecthandler' => [ + 'target' => \TYPO3\CMS\Redirects\Http\Middleware\RedirectHandler::class, + 'before' => [ + 'typo3/cms-frontend/base-redirect-resolver', + ], + 'after' => [ + 'typo3/cms-frontend/authentication', + ], + ], + ], +]; diff --git a/Configuration/Services.yaml b/Configuration/Services.yaml new file mode 100644 index 0000000..40e95b3 --- /dev/null +++ b/Configuration/Services.yaml @@ -0,0 +1,20 @@ +services: + _defaults: + autowire: true + autoconfigure: true + public: false + + TYPO3\CMS\Redirects\: + resource: '../Classes/*' + + extension.configuration.redirects: + class: 'array' + factory: + - '@TYPO3\CMS\Core\Configuration\ExtensionConfiguration' + - 'get' + arguments: + - 'redirects' + + TYPO3\CMS\Redirects\Configuration\CheckIntegrityConfiguration: + arguments: + $extensionConfiguration: '@extension.configuration.redirects' diff --git a/Configuration/Sets/redirects/config.yaml b/Configuration/Sets/redirects/config.yaml new file mode 100644 index 0000000..0bdb82b --- /dev/null +++ b/Configuration/Sets/redirects/config.yaml @@ -0,0 +1 @@ +name: typo3/redirects diff --git a/Configuration/Sets/redirects/labels.xlf b/Configuration/Sets/redirects/labels.xlf new file mode 100644 index 0000000..419ceb6 --- /dev/null +++ b/Configuration/Sets/redirects/labels.xlf @@ -0,0 +1,35 @@ + + + +
+ + + Redirects + + + Redirects + + + Automatically update slugs of all sub pages + + + Automatically create redirects for pages with a new slug (works only in LIVE workspace) + + + This feature works only in LIVE workspace. + + + Time To Live in days for redirect records to be created + + + The value `0` disables expiration. + + + HTTP status code for automatically created redirects + + + See https://developer.mozilla.org/en-US/docs/Web/HTTP/Redirections#Temporary_redirections for more information. + + + + diff --git a/Configuration/Sets/redirects/settings.definitions.yaml b/Configuration/Sets/redirects/settings.definitions.yaml new file mode 100644 index 0000000..be22798 --- /dev/null +++ b/Configuration/Sets/redirects/settings.definitions.yaml @@ -0,0 +1,20 @@ +categories: + redirects: ~ + +settings: + redirects.autoUpdateSlugs: + type: bool + default: true + category: redirects + redirects.autoCreateRedirects: + type: bool + default: true + category: redirects + redirects.redirectTTL: + type: int + default: 0 + category: redirects + redirects.httpStatusCode: + type: int + default: 307 + category: redirects diff --git a/Configuration/TCA/Overrides/sys_redirect.php b/Configuration/TCA/Overrides/sys_redirect.php new file mode 100644 index 0000000..5981e84 --- /dev/null +++ b/Configuration/TCA/Overrides/sys_redirect.php @@ -0,0 +1,5 @@ + [ + 'title' => 'LLL:EXT:redirects/Resources/Private/Language/locallang_db.xlf:sys_redirect', + 'descriptionColumn' => 'description', + 'label' => 'source_host', + 'label_alt' => 'source_path', + 'label_alt_force' => true, + 'crdate' => 'createdon', + 'tstamp' => 'updatedon', + 'hideTable' => true, + 'versioningWS' => false, + 'groupName' => 'system', + 'default_sortby' => 'source_host, source_path', + 'rootLevel' => -1, + 'security' => [ + 'ignoreWebMountRestriction' => true, + 'ignoreRootLevelRestriction' => true, + 'ignorePageTypeRestriction' => true, + ], + 'delete' => 'deleted', + 'enablecolumns' => [ + 'disabled' => 'disabled', + 'starttime' => 'starttime', + 'endtime' => 'endtime', + ], + 'typeicon_column' => 'redirect_type', + 'typeicon_classes' => [ + 'default' => 'mimetypes-x-sys_redirect', + 'qrcode' => 'actions-qrcode', + 'short_url' => 'module-urls', + ], + 'type' => 'redirect_type', + ], + 'types' => [ + 'default' => [ + 'showitem' => ' + --div--;core.form.tabs:general, --palette--;;source, --palette--;;targetdetails, protected, --palette--;;internals, + --div--;redirects.db:tabs.redirectCount, disable_hitcount, hitcount, lasthiton, createdon, + --div--;core.form.tabs:access, --palette--;;visibility, + --div--;core.form.tabs:notes, description, redirect_type', + 'columnsOverrides' => [ + 'source_host' => [ + 'config' => [ + 'default' => '*', + ], + ], + ], + ], + 'qrcode' => [ + 'title' => 'LLL:EXT:redirects/Resources/Private/Language/locallang_db.xlf:sys_redirect.redirect_type.qr_code', + 'showitem' => ' + --div--;core.form.tabs:general, --palette--;;qrcode_target,qrcode_display, + --div--;redirects.db:tabs.redirectCount, disable_hitcount, hitcount, lasthiton, createdon, + --div--;core.form.tabs:access, --palette--;;visibility, + --div--;core.form.tabs:notes, description, redirect_type + ', + ], + 'short_url' => [ + 'title' => 'LLL:EXT:redirects/Resources/Private/Language/locallang_db.xlf:sys_redirect.redirect_type.short_url', + 'showitem' => ' + --div--;core.form.tabs:general, --palette--;;short_url_target, + --div--;redirects.db:tabs.redirectCount, disable_hitcount, hitcount, lasthiton, createdon, + --div--;core.form.tabs:access, --palette--;;visibility, + --div--;core.form.tabs:notes, description, redirect_type + ', + ], + ], + 'palettes' => [ + 'visibility' => [ + 'showitem' => 'disabled, --linebreak--, starttime, endtime', + ], + 'source' => [ + 'showitem' => 'source_host, source_path, --linebreak--, respect_query_parameters, is_regexp', + ], + 'targetdetails' => [ + 'showitem' => 'target, target_statuscode, --linebreak--, force_https, keep_query_parameters', + ], + 'internals' => [ + 'showitem' => 'creation_type, integrity_status, --linebreak--, createdby', + ], + 'qrcode_target' => [ + 'showitem' => 'source_host, target, --linebreak--, createdby, force_https', + ], + 'short_url_target' => [ + 'showitem' => 'short_url, --linebreak--, target, --linebreak--, createdby, force_https', + ], + ], + 'columns' => [ + 'redirect_type' => [ + 'config' => [ + 'type' => 'passthrough', + 'default' => 'default', + ], + ], + 'qrcode_display' => [ + 'config' => [ + 'type' => 'none', + 'renderType' => 'qrCode', + ], + ], + 'short_url' => [ + 'label' => 'LLL:EXT:redirects/Resources/Private/Language/locallang_db.xlf:sys_redirect.short_url', + 'description' => 'LLL:EXT:redirects/Resources/Private/Language/locallang_db.xlf:sys_redirect.short_url.description', + 'config' => [ + 'type' => 'none', + 'renderType' => 'shortUrl', + 'fieldControl' => [ + 'shortUrlGenerator' => [ + 'renderType' => 'shortUrlGenerator', + 'options' => [ + 'title' => 'LLL:EXT:redirects/Resources/Private/Language/locallang_db.xlf:sys_redirect.short_url.shortUrlGenerator', + ], + ], + ], + ], + ], + 'source_host' => [ + 'label' => 'LLL:EXT:redirects/Resources/Private/Language/locallang_db.xlf:sys_redirect.source_host', + 'config' => [ + 'type' => 'input', + 'required' => true, + 'eval' => 'trim,' . \TYPO3\CMS\Redirects\Evaluation\SourceHost::class, + // items will be extended by local sys_domain records using dataprovider TYPO3\CMS\Redirects\FormDataProvider\ValuePickerItemDataProvider + 'valuePicker' => [], + ], + ], + 'source_path' => [ + 'label' => 'LLL:EXT:redirects/Resources/Private/Language/locallang_db.xlf:sys_redirect.source_path', + 'config' => [ + 'type' => 'input', + 'size' => 30, + 'required' => true, + 'eval' => 'trim', + 'placeholder' => 'LLL:EXT:redirects/Resources/Private/Language/locallang_module_redirect.xlf:source_path.placeholder', + 'max' => 2048, + ], + ], + 'force_https' => [ + 'exclude' => true, + 'label' => 'LLL:EXT:redirects/Resources/Private/Language/locallang_db.xlf:sys_redirect.force_https.0', + 'config' => [ + 'type' => 'check', + 'renderType' => 'checkboxToggle', + 'default' => 0, + ], + ], + 'keep_query_parameters' => [ + 'exclude' => true, + 'label' => 'LLL:EXT:redirects/Resources/Private/Language/locallang_db.xlf:sys_redirect.keep_query_parameters.0', + 'config' => [ + 'type' => 'check', + 'renderType' => 'checkboxToggle', + 'default' => 0, + ], + ], + 'respect_query_parameters' => [ + 'exclude' => true, + 'label' => 'LLL:EXT:redirects/Resources/Private/Language/locallang_db.xlf:sys_redirect.respect_query_parameters.0', + 'config' => [ + 'type' => 'check', + 'renderType' => 'checkboxToggle', + 'default' => 0, + ], + ], + 'target' => [ + 'label' => 'LLL:EXT:redirects/Resources/Private/Language/locallang_db.xlf:sys_redirect.target', + 'config' => [ + 'type' => 'link', + 'required' => true, + 'allowedTypes' => ['page', 'file', 'url', 'record'], + 'appearance' => [ + 'allowedOptions' => ['params'], + ], + ], + ], + 'target_statuscode' => [ + 'exclude' => true, + 'label' => 'LLL:EXT:redirects/Resources/Private/Language/locallang_db.xlf:sys_redirect.target_statuscode', + 'config' => [ + 'type' => 'select', + 'renderType' => 'selectSingle', + 'items' => [ + [ + 'label' => 'LLL:EXT:redirects/Resources/Private/Language/locallang_db.xlf:sys_redirect.target_statuscode.301', + 'value' => 301, + 'group' => 'change', + ], + [ + 'label' => 'LLL:EXT:redirects/Resources/Private/Language/locallang_db.xlf:sys_redirect.target_statuscode.302', + 'value' => 302, + 'group' => 'change', + ], + [ + 'label' => 'LLL:EXT:redirects/Resources/Private/Language/locallang_db.xlf:sys_redirect.target_statuscode.303', + 'value' => 303, + 'group' => 'change', + ], + [ + 'label' => 'LLL:EXT:redirects/Resources/Private/Language/locallang_db.xlf:sys_redirect.target_statuscode.307', + 'value' => 307, + 'group' => 'keep', + ], + [ + 'label' => 'LLL:EXT:redirects/Resources/Private/Language/locallang_db.xlf:sys_redirect.target_statuscode.308', + 'value' => 308, + 'group' => 'keep', + ], + ], + 'itemGroups' => [ + 'keep' => 'LLL:EXT:redirects/Resources/Private/Language/locallang_db.xlf:sys_redirect.target_statuscode.keep', + 'change' => 'LLL:EXT:redirects/Resources/Private/Language/locallang_db.xlf:sys_redirect.target_statuscode.change', + ], + 'default' => 307, + ], + ], + 'hitcount' => [ + 'exclude' => true, + 'label' => 'LLL:EXT:redirects/Resources/Private/Language/locallang_db.xlf:sys_redirect.hitcount', + 'config' => [ + 'type' => 'input', + 'size' => 5, + 'default' => 0, + 'readOnly' => true, + ], + 'displayCond' => 'USER:TYPO3\CMS\Redirects\UserFunctions\HitCountDisplayCondition->isEnabled', + ], + 'lasthiton' => [ + 'exclude' => true, + 'label' => 'LLL:EXT:redirects/Resources/Private/Language/locallang_db.xlf:sys_redirect.lasthiton', + 'config' => [ + 'type' => 'datetime', + 'readOnly' => true, + ], + 'displayCond' => 'USER:TYPO3\CMS\Redirects\UserFunctions\HitCountDisplayCondition->isEnabled', + ], + 'createdon' => [ + 'exclude' => true, + 'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_general.xlf:LGL.creationDate', + 'config' => [ + 'type' => 'datetime', + 'readOnly' => true, + ], + 'displayCond' => 'USER:TYPO3\CMS\Redirects\UserFunctions\HitCountDisplayCondition->isEnabled', + ], + 'disable_hitcount' => [ + 'exclude' => true, + 'label' => 'LLL:EXT:redirects/Resources/Private/Language/locallang_db.xlf:sys_redirect.hitcountState', + 'config' => [ + 'type' => 'check', + 'renderType' => 'checkboxLabeledToggle', + 'items' => [ + [ + 'label' => '', + 'labelChecked' => 'LLL:EXT:core/Resources/Private/Language/locallang_general.xlf:LGL.enabled', + 'labelUnchecked' => 'LLL:EXT:core/Resources/Private/Language/locallang_general.xlf:LGL.disabled', + 'invertStateDisplay' => true, + ], + ], + ], + 'displayCond' => 'USER:TYPO3\CMS\Redirects\UserFunctions\HitCountDisplayCondition->isEnabled', + ], + 'is_regexp' => [ + 'exclude' => true, + 'label' => 'LLL:EXT:redirects/Resources/Private/Language/locallang_db.xlf:sys_redirect.is_regexp', + 'config' => [ + 'type' => 'check', + 'renderType' => 'checkboxToggle', + ], + ], + 'protected' => [ + 'exclude' => true, + 'label' => 'LLL:EXT:redirects/Resources/Private/Language/locallang_db.xlf:sys_redirect.protected', + 'description' => 'LLL:EXT:redirects/Resources/Private/Language/locallang_db.xlf:sys_redirect.protected.description', + 'config' => [ + 'type' => 'check', + 'renderType' => 'checkboxToggle', + ], + ], + 'creation_type' => [ + 'label' => 'LLL:EXT:redirects/Resources/Private/Language/locallang_db.xlf:sys_redirect.creation_type', + 'description' => 'LLL:EXT:redirects/Resources/Private/Language/locallang_db.xlf:sys_redirect.creation_type.description', + 'config' => [ + 'type' => 'select', + 'renderType' => 'selectSingle', + 'items' => [ + [ + 'label' => 'LLL:EXT:redirects/Resources/Private/Language/locallang_db.xlf:sys_redirect.creation_type.0', + 'value' => 0, + ], + [ + 'label' => 'LLL:EXT:redirects/Resources/Private/Language/locallang_db.xlf:sys_redirect.creation_type.1', + 'value' => 1, + ], + ], + 'default' => 1, + 'readOnly' => true, + ], + ], + 'createdby' => [ + 'label' => 'LLL:EXT:redirects/Resources/Private/Language/locallang_db.xlf:sys_redirect.createdby', + 'description' => 'LLL:EXT:redirects/Resources/Private/Language/locallang_db.xlf:sys_redirect.createdby.description', + 'config' => [ + 'type' => 'passthrough', + 'renderType' => 'creationInformation', + 'default' => 0, + ], + ], + 'integrity_status' => [ + 'label' => 'LLL:EXT:redirects/Resources/Private/Language/locallang_db.xlf:sys_redirect.integrity_status', + 'description' => 'LLL:EXT:redirects/Resources/Private/Language/locallang_db.xlf:sys_redirect.integrity_status.description', + 'config' => [ + 'type' => 'select', + 'renderType' => 'selectSingle', + 'dbFieldLength' => 180, + 'default' => RedirectConflict::NO_CONFLICT, + 'items' => [ + [ + 'label' => 'LLL:EXT:redirects/Resources/Private/Language/locallang_db.xlf:sys_redirect.integrity_status.no_conflict', + 'value' => RedirectConflict::NO_CONFLICT, + ], + [ + 'label' => 'LLL:EXT:redirects/Resources/Private/Language/locallang_db.xlf:sys_redirect.integrity_status.self_reference', + 'value' => RedirectConflict::SELF_REFERENCE, + ], + ], + 'readOnly' => true, + ], + ], + ], +]; diff --git a/Documentation/Basics/Index.rst b/Documentation/Basics/Index.rst new file mode 100644 index 0000000..c7ba2db --- /dev/null +++ b/Documentation/Basics/Index.rst @@ -0,0 +1,136 @@ +.. include:: /Includes.rst.txt + +.. _glossary: +.. _basics: + +====== +Basics +====== + +This page defines and explains some basics and basic terms which are not +specific to EXT:redirects. + +.. todo: add term for t3:// URI (e.g. typo3 URI, linkhandler URI, etc.) after term is clarified +.. see https://forge.typo3.org/issues/95820 + +.. _basics-url: + +Components of a URL +=================== + +A URL contains the following components: +`scheme://host:port/path?query-parameters#fragment` + +Example: https://example.org/path?key=value#c123 + +If the following terms are used in this documentation, it refers to the parts +of the URL: + +- scheme +- host +- path +- query parameters +- fragment + +.. _http-status-codes: + +HTTP status codes +================= + +When redirecting, a HTTP status code is sent to the client (usually a browser +or a bot). This status code informs the client +about the type of redirect. We differentiate between a permanent and a temporary +redirect. + +For a full list of possible HTTP status codes for redirects (e.g. 301, 302, 307 +etc), see https://developer.mozilla.org/en-US/docs/Web/HTTP/Status. + +* `301 `__: + Moved permanently +* `302 `__: + Found +* `303 `__: + See other +* `307 `__: + Temporary redirect +* `308 `__: + Permanent redirect + +.. note:: + + Which redirect to use for which use cases is beyond the scope of this + documentation. We give you some pointers here, but information like this + can be outdated and it is best to read up on this elsewhere. + +As rule of thumb: + +There are "temporary" and "permanent" redirects. 301 and 308 are "permanent" +redirects. + +.. attention:: + + Don’t use a 301 if you ever want to use that specific (source) URL ever again. + +Source: `6 questions about redirects for SEO (Yoast) +`__ + +.. attention:: + + For routine redirect tasks, 301 (permanent redirect) and 307 (temporary + redirect) status codes can be used depending on what type of change you + are implementing on your website. + +Source: `A Technical SEO Guide to Redirects (SEJ) +`__ + +For automatically created redirects it is not recommended to use 301. You can +use 307, which is also the default in the redirects extension. However, +if you create redirects manually, it **may** make sense to use 301 for these. + +With permanent redirects (301 and 308) the "link juice" (ranking factor) is +transferred to the redirect target. The search engines are notified this way +that the URL has changed permanently and that they should update their index +accordingly. Thus, from SEO point of view, permanent redirects are often a good +choice. If domains are changed or sites restructured, 301 are often used. + +.. _redirect-chain: + +Redirect chain +============== + +Contrary to the redirects loops, the pages can still be loaded. Redirect +chains are inefficient because a number of redirects must be processed before +the final page is loaded. + +Examples for redirect chains: + +- `/a => /b => /c` (it would be more efficient if `/a` redirected to `/c` + directly and `/b` redirected to `/c`) + +.. _redirect-loop: + +Redirect loop +============= + +A number of one or more redirects which will cause a loop by redirecting back +to the origin. The page can no longer be loaded and a HTTP status code 500 is +usually returned. + +Examples for redirect loops: + +- `/a => /a` (source and target for a redirect resolve to the same URL) +- `/a => /b => /a` + +.. _slug: + +Slug +==== + +A slug is the part of the URL path specific to the page. The slug is stored as +:sql:`pages.slug` in the database. The slug does not necessarily exactly reflect +the URL path which is used in the URL to access the page. The actual URL may +depend on the entry point configured in the site configuration, additional route +enhancers and decorators. + +Example: A slug `/path` is used, the final URL may be +`https://example.org/en/path.html`. diff --git a/Documentation/BestPractices/Index.rst b/Documentation/BestPractices/Index.rst new file mode 100644 index 0000000..da7b870 --- /dev/null +++ b/Documentation/BestPractices/Index.rst @@ -0,0 +1,120 @@ +.. include:: /Includes.rst.txt + +.. _best-practices: + +============== +Best practices +============== + +Here are some general tips for managing redirects: + +- Check for conflicts regularly with + :ref:`redirects:checkintegrity `. This is no + longer as much an issue as with previous versions, because it was resolved + with `patch 68202 `__. + Since this patch, a path is no longer used for the target. A redirect is + constructed using the page ID, e.g. `t3://page?uid=1` as target. This means + the redirect will still work, even if the slug changes again. This way, it + is less likely that :ref:`redirect loops ` and + :ref:`redirect chains ` are created because the redirect + always redirects directly to the target page. +- Check number of redirects and regularly clean out unnecessary redirects, + e.g. with :ref:`redirects:cleanup `. If you use the + :ref:`hit counter `, be aware that it comes with a small + performance impact. +- :ref:`"Redirect chains" ` are not as much a problem, but can + become inefficient. A "redirect chain" are several redirects which must be + followed until the destination is reached. Ideally, these should be merged. + +.. _best-practices-editors: + +Editors +======= + +Well curated content and editors which have a good understanding of SEO and +possible problems with redirects are a good idea in any case. TYPO3 comes +with extensive :ref:`permission ` and +:doc:`workspaces ` management - which gives you the +possibility to only grant advanced editor groups access to parts of the content +(e.g. pages, redirect module) which they are well equipped to handle. + +- If you give editors access to the redirects module, make sure that they + understand the usage and for example do not create + :ref:`redirect loops `. +- Often changing slugs comes with a cost. Redirects are a counter measure + so that pages with changing slugs are still accessible but a better + strategy is to only change slugs when absolutely necessary. + +.. _best-practices-performance: + +Performance +=========== + +With a certain number of redirects and depending on your setup, performance +problems *may* occur through technical limitations. + +The following rules of thumb should be followed: + +- Restrict time-to-live [`ttl`] of redirects - manual and automatically + created. +- Cleanup regularly and remove outdated redirects. +- Recheck redirects and aggregate them on a manual basis to lower the number. +- Keep the number of redirects for the whole instance in a certain range. +- Instruct editors to be careful with slug changes and thus creating redirects + automatically, which may be unnecessary. + +.. note:: + + Handling redirects through PHP applications has technical limitations, + even more if complex redirects like regexp-style redirects should be + supported. Thus, handling redirects with EXT:redirects is only suitable for + installations with a certain number of redirects. + + It is recommended to monitor performance and - if necessary - export + redirects to your webserver configuration or load balancer. + +.. _best-practices-troubleshooting-tools: + +Troubleshooting tools +===================== + +Since redirects are resolved in the web browser, it may be difficult to +troubleshoot. There are many tools available, for example you can +use a command line tool like `curl` to follow and show redirects or use the +online tool `Redirect detective `__. +Redirect detective also detects redirect loops. + +.. figure:: ../Images/RedirectDetective.png + :class: with-shadow + + Output of Redirect Detective for `http://typo3.org`. + +Example: Resolve redirects with curl (`-L` follows redirects): + +.. code-block:: shell + + curl -I -L -s -X GET http://example.org + +Output: + +.. code-block:: shell + + HTTP/1.1 301 Moved Permanently + ... + Location: http://example.com + .... + + HTTP/1.1 301 Moved Permanently + ... + Location: https://example.com + .... + + HTTP/1.1 200 OK + ... + +As you can see, `http://example.org` is redirected twice, first to +`http://example.com` and then to the HTTPS variant. + +.. note:: + + These are just two simple tools of many you can use. diff --git a/Documentation/Events/Index.rst b/Documentation/Events/Index.rst new file mode 100644 index 0000000..2addc79 --- /dev/null +++ b/Documentation/Events/Index.rst @@ -0,0 +1,75 @@ +.. include:: /Includes.rst.txt + +.. _psr14events: + +============= +PSR-14 events +============= + +The following PSR-14 events are available to extend the functionality: + +.. _AfterAutoCreateRedirectHasBeenPersistedEvent: + +AfterAutoCreateRedirectHasBeenPersistedEvent +============================================ + +React on persisted auto-created redirects. +:ref:`More details ` + +.. _BeforeRedirectMatchDomainEvent: + +BeforeRedirectMatchDomainEvent +============================== + +Implement a custom redirect matching upon the loaded redirects or return a +matched redirect record from other sources. +:ref:`More details ` + +.. _AfterPageUrlsForSiteForRedirectIntegrityHaveBeenCollectedEvent: + +AfterPageUrlsForSiteForRedirectIntegrityHaveBeenCollectedEvent +========================== + +Fetch and alter the list of URLs checked when calling the :ref:`redirects:checkintegrity ` +command. +:ref:`More details ` + +.. _ModifyAutoCreateRedirectRecordBeforePersistingEvent: + +ModifyAutoCreateRedirectRecordBeforePersistingEvent +=================================================== + +Modify the redirect record before it is persisted to the database. +:ref:`More details ` + +.. _ModifyRedirectManagementControllerViewDataEvent: + +ModifyRedirectManagementControllerViewDataEvent +=============================================== + +Modify or enrich view data for the +:php:`\TYPO3\CMS\Redirects\Controller\ManagementController`. +:ref:`More details ` + +.. _RedirectTargetIntegrityCheckEvent: + +RedirectTargetIntegrityCheckEvent +================================= + +Validate redirect targets during the integrity check and flag broken redirects. + +.. _RedirectWasHitEvent: + +RedirectWasHitEvent +=================== + +Process the matched redirect further and adjust the PSR-7 response. +:ref:`More details ` + +.. _SlugRedirectChangeItemCreatedEvent: + +SlugRedirectChangeItemCreatedEvent +================================== + +Manage the redirect sources for which redirects should be created. +:ref:`More details ` diff --git a/Documentation/Images/EditAction.png b/Documentation/Images/EditAction.png new file mode 100644 index 0000000000000000000000000000000000000000..aef6df6f638e8a13c5b6caadc24dda80fa5853bb GIT binary patch literal 427 zcmV;c0aX5pP)e#ytT})jz)LXP0uNxzDF~cF6Nv3n zYP7I0)2_aPgqW8+Sa3Dwg{ijL|Jz}fES^p$cz; V&I0*2$Bh60002ovPDHLkV1k~|!G{0< literal 0 HcmV?d00001 diff --git a/Documentation/Images/InstallActivate.png b/Documentation/Images/InstallActivate.png new file mode 100644 index 0000000000000000000000000000000000000000..9dfd3d0352d3929ef33c2d1dbfd429167fa2cbdb GIT binary patch literal 20679 zcmb5V1ymec(=N;jAtVq&f&_;IcXt>_4i+R3++7AIcwmqaJh=N1++Bu2gEQD5!7afi zz~C@A{6o%p@BRLJ*SEfPo3-d()4OZ$uDy3vJx^7Ke^8dg!y(7Pz`($he_ZSvj+XUQ=lTSf^+arblQ2#t7PjQevS*y5&7w^>Uw&eT)deQxMkJnh zLw9@{ z<9%B3vQwy}93;Jl^e16BFwJznhoWaPqp{N~Te#<_R>4PE+;##hoCaF?>AFt@QTv@w z%8Hjqg_8EA>-%zko}mo#LwaYODYroqHR`fVh_GERZ`n#h2Y2eYb~04i^*Il_*%Jb3 zX6C%lwD!{0Q}Q*j-)^jN?>_IO3>3tcVaP^fC4PJBar4vvphcfejAxGlEAP!!#G233 zzXA-uX*p-^oA;MwVcY_LDma}hYMN{&oG3`)()`u7@V(bvN*(oii0a!NEcX3e(lvju z{mW01c}@zLzK}OF=`;GiG)4Gt;+wy&U|@%S{}^36qu<(62Zi{)14c=Y~}{TZO5YLKx`5H8)k%$dH)!(~QKAe+z?y_sHH zuU1>=C40ufJhmh;<5%ZZH5nR#eJ}qz82fxU!<5&NC!M#|9OB(LzEg2}Q4|W$)`RVB zpEaxiGDl9XY}=M)J$N->whZ?+(&Gfid$F})=|6gxR$4yRWr95Oo${G%D9*Mr@;l5D^((krhqG5b;6fDOe)I9JiTl!xu z%EG{Nq7b&3E%vk88`iuP&#n#n6_&#wIWBz;TPqU~>Zgi0mh%iOR1%ip2dw8UWG#B$Smc;naY`Ur0+A}l^2Z=>`S z#%cU&%zAt{PCieHMrNFZ5YkJ`UoHxC$<=$kBT(htGU>^O8gAyG&~&j;VXgIn^0ZaL ztvo@y`U1{8f6wKAb$_W{YV=Y72ZdbDN#uQ6H#S>_YYOSFPgP1PMSZ#d&|A3pmT^yE&fjC>zb}R4XFvB1@?0qweRyWN@C( zSzR_~#qvYnyHs7AQ@t$51_kPoj8V!qb)wfO60%j&WeIJHVR@^`mu*&Yk>T;B@!|op zWsWs!^2FQ<~20d;3>vZ!g4g&QpNJfHZ#?zhiii{#p z+DCY31%76+hx5~BSQB4%*2p}<;u@T1fBWlt%T9zXVqu*C>)FjRfoDv)7CEu%3qGOQ z6An@HQvJk)pPo*dE^Ocm&r3NIxWo>tqrSz^NF~^WX-8@O#0WfF_YUqzto_?M*oa>_ zyF+xTT0>9+#3*XCdpeEs3#To=^^r0l@_ZeuBn3^$x)jNDCI9LzIGmDiMr-MKt{A!K z7p?VM4RrbHvMgbW$0C;%S_!Fkp&%$)V$*3@sSDIEr`KEJyT;yaB4e1e3iC)=L9m{M z>^M1rp6wNr=AHuhYAhKG=nUof%<)@Nm4|7~wY+rca+q~~Y5Jb~^)A2W4 zDdVr>m>Y3lYZ|+VBMi20ZbI>{8W8Wst^=5Zw|WkCp^F1g>>~TflP|_?H$r%mx4v8= z5t?dU%#F?6Gk&gg)2BwS>{gf&-IWH;;$lr@4=!XuTyrt@XszJfezAyPQ({`uUmcjy zY-wu?5>TjozuVSXuGzgS1(lHG-{6%D-bzS%(TM0qUep>2ML^a8zryFhOTL^gAx5bN z>D1;Qf7P(xFZ5I+)Z$EdV4T$jW6?W@HDThcsygt&N;%+4cjZZhR$q*0rkFiwb3={R zFl)Jpvh{>df$;QizkFP?99UF9+=6Sp0o0&k?`ERtq;YuKZOPt#|2 z+Xzhd^evn@mHIfPYfvP+rPtCSxl-UJhIhRYAehHA@3_hpx?UDP)2M^7L$Cr5ggogYX=q8Htx|V&Mg@C2VG>1QRGwz!Zu&G-vlynx{qU)L5 z+hmld_sTri+dIytF2oLA?v6!61kRhNm^t;cNvac`;ycjoMH)%;n)uPnQ18Cj@xgAW zEjFp@X@?&~*uxoo9s(39^@?y;Vjps11%I`$SG#UILhTaZ8Y(AnWOnY1;;y z_cTCAC>~F&a`1Z5QVKwv|AOF5Q5&0_2OlD*#-4gwHLA5tt(HrT{_%m1RG`ft!*(lg zpC&zt8p;v03Zo{U=m1=~yifP)d}w!J*5Hvao@t>0Cr<-_5>lQWJb%MU(W3+NcOpq~ z|IRKbN6O^*qaReMs5M@h%$wD=zbRJ_BtNA3=^RovfgJ&md#>f@Te%*UQ<+U5+o<7G zVu4)^JoCW1JLy?}r=oI_4s9D@_W1OGj`gX&BTvvJ(Ge#2gofykUrcpGQJ&Cei%pg5 zE6L5qmIPbO`61_Mkb%tVZ5F#*=I<=*mLHqLlY>ku8@YJdr-W9h+i4aaOwRU$FN`AB zyG#W4lf)LKY7qqOEwA&GE2%z-g*d@GZ`Ny=wlv*>-j+=KxJ#v2UgAgDM-!o4h&56K z=E)a)I3vozWwZKwjO`eY)D`o7ZXxl2P~>n!5>32mTD-;BmiyG*sHqv=eVXpp2Xf0=+&khk?LtXYsjH9S%fRO0>C!N@Z zVY?#2sePiLsyUJ~jvOH`XyP-B_~jP5^r}S$g&wQPnIo#-o7NS%JRxl*-dMhGmYHYk zR$%xtw8k{#xqG$hl2ve07=N#>c#!LF-p~8O%TA;$Eqwm#Cs z$xgL=JM6(+)PcwPRi+VdQ@$lC zLa9Sp_gH+}kRqgcQL5ISPts~SYmQSd$jy@nOv_-Y#gpvGe5!5K zS0u%CwpLi!-#%3FkqU18z=e6zAIT_OJkS0y^3KwA{iwa_!n;NCq=`_sDy~R3G+O^t ztR2=CY%!ehs!UTO(R z&hvNF$f~QkxWvP%6*>#5p8nDeOxElK;;|;%opNn83~doMl3m!AX}6|?=#f?h8~qwq zwGm1i2C>6hB2TbqPe-i5M5n&Db-OK-xC6o4yyrBKRs7-vJxHS1XvzXqkmch|B{7U3TPv?wOaK)yr9 z4gwEpo{pl!ALPf&SvSrM(|i@hUy#2O{N~=%l>lM{o?N(Iir!wZJOPNX+lT7{nCit{ zUe{>8GduwCMW$n^9o81OYFq+xdE!Ik*gKompKf2Yr3f0E4^k=6FCZ3kG*2j*p71Hk zbDDRB+ERO)a)KrxXL_#?t3!BO?&n$esMCaX_W1YMwmU*>SJwjwP1%(L%!$Ud^z4@G zY)tuynzt{Ay)%}~E?V5Ew7M~6W57L;Q7CRh9upmIupFIV`YYh-BRoXU_m$Sey8Tw3 zK*|=^9HKrvZcJv&0nBVwQU!l!pEJ^~T-y8CTpHTeyVySH4&9j@U$7-z&T zzE@cNiy^*){{bSO0e?&idVK%gjsFgv{6E8%|8$A*f8v?{+(`W!g&Y#FA?b(Zl0BhI z;`%{a*P7R2wqhhe$RI>?dKabF;#C#V`}$s;7#ImlUvLg?8N$B*y@odz60qmSH+QCk& zq1y~CD)oK{*|Z>niW;M5=VcX_=fXDpbXi4$qUT)q!CIuZU9AUL1KPc|kY`3(;M^e( zkQ*EzY7TTnJZs2oH7r(f>QVg%{h8asE3`PqXGnvs66WGJj&Lpr8(^i@;r24MWfP7% zJ=}^3Z9PQ^8cK;ub^ROjd2ufg%e#1PEl=xUNEi3qxz&U7op?>PaJLd3fY4Ht25X$n zq<#rN(K4dG5MZ_Rw=6*cw-%~=#TY2W9{X-{XL6{`9d zCN~RdxLczFWGLPU=AxLuee}@WJ4BwiyXvyQ?TS;4I^3*`BJ>}C8d(6qeqaxk$^I5A zbNrWyY@t%UcMGP3QKCC^kwnQ&*H!-hY)$yjI{wVlfOzFMt!^Q}IKa^3rml0Qg@u?H ze|i`H?!3pcv8UXSIVGOusgwjUGELVp2S&PRpz0hFZ-l#-bcP(04+j*X%;8XPT_RmJ zLl#}Eyv1L?cGFam2PeAelD-1twpm$WAc!GT0(Y6|Kl{ameRmGQO)*VT?w)gZ@w>{b zg^3g-@%wPv8>*d^a_XASmR6zYg!fPn)C&#=Oh)26R+^Xy|Ey}%N9klnb=LsNLCY5S zmG#p*<~GmMXRQB)_Nh7`D=Ja1L}^iD^Tn*pOp6JQWcl3nO9TIJ^W~Ar1KO#(49Y{Y zNiI-WueTB|L%gn@V)#&car_J1XrUIge@L@ZR7@c+hi>(KDv|DE;Ka9u!xYKnBj?vY zUU9ZW9+O$MkH+u2>`k+h*u<;+y92-H*8m+Uqlf~natsC;6y9y%PK6NRyMJ&heJh|c zDD9jF9Q(!PQ{r6^X=;Jv5I+PpWQj^HT~^w$gahc@xod;<4pS*^7F@r#Xw$`5rOxT) z&fgNgTr#WV9%c{r5R!b%|1Y(nME)PE@}Ir`zwFR|Zv0PV2*&@!lKgMU*bm;nf8QTX zHH2wG`^vp4Ki}eSEkj*hSS|ZJ76t|>&+hJSK1Z93vd*1rgmVO?mRf+k0CgjP(D(4d zhl+|S3nP*%e>yHgNX?VUVhRrrZ(YcBtq(8r4sA3a#-fh%VkAU8_ZB%^Mrr@{3S)BD zlY+$ABRN)I=Ras!+WX7mRV{$fb8CD1=;~$$gVgw4MT^@OK}o~Yha>n;BSl=vaa=T=393)Jvi z)^i+XsZzurW&L7iR5V({IMAdEER!kWdpRn$pDE&se#Pidmo5A4L)^oXcgK?VS~6*# zcO5Ofa_ZR0F{BB-brQKo0me;DIcM){ol9*>(l9&gLYJ};Uj3t9jUU!wx; zJPPr=rpwp0jUdq}fCM~BU(o0$L64JY)s4|mvuqJ@-`zCfk08Wjyz|MGitDrr!Y@xi zFAuwLx!%zev@9*3(Ivp7B`()!?jEBz&Sx8IczgF{u$_m4@9!MrBxQNfsG^^O#Bp2- zKIMkYeDh8CEZNG6xU~N$Xe}J{qYBXpF3JTEW*OGqcy{POk->?_Ju$AHj-SkQyUd@k zWv^`Ik8f0N6Lk+-4r$wFuQ+kOxAbDAKXbYT?w2whto<+S=Ic%2tBPdq`LzHKpv4u5kWz%0yUZI z;)VtRM#jyRyPK7Jv&wygoJ(omyv?RO@@-}F%c-fi#F=+ZKJ2uv;iP#lmg>O|Z~i65 zM?IHJoOxn*I(!8d`FN5=vbG=5CgxFSzv+9()ou=P^!sFWVk`&0M$u*9Z_@aB)?JF# z@a>03-Jk;D*U8z=`{|&4kXaxqZl9}32(@6l=D4Pg?{Cb*gj(+RPZETp4f|q)%dbVK z^ZIG*?Hh(Ht>*o?(7mXrHsBuS9av**K*b9*f_c_AdSiuL#Lo8JUtPh^rj$3 zwzJ#C9K0cLUD-;xbCkkwlH2WLxRr9AWM)v1Nex z$B#OMT%=5Ei-(oH&Jmv+cSY{x;cEOy^r+pl?)aj+MTH{GJHL%uZV>fHBLr8n{o@f( zMT83K@!WAk{O%C-LZ(Z3Gx5Cm#qYg639lo1V(Ze;bz@%VNaTgn9m)ysk(7V7#O3ci zg#v96~a0OAIIpeoX@GpT&p7^JD?7I5E_HJr9t_V z91>zaYX13wA}4X@Ghd3^RDZWKLcV>kkKM{~=D4WqP~cvR`i=L;X7}zdX)4#r5JXG1 z!sfB)vaaX;aFWvjjC#L3&2>M0Ma2xnDY)zpuL})SxAowF`fmJXytHr#x%&+;=H}l$ z9#^4|B1ADP-UjA3qZigZ({0(;v)sC?hP#uW@sEum__@o=xXoEC ziu1|DBIoBig8i)+M3ACwnna;BorycErVmFrBz4+dIW#*r$ zw0n`}q~Zwj_>Jg$qV5BtX#w1hqz@VcKF+8-J58@CIBHALlBhojz!q;Z z3hawed>Xx6^V9BGpjsG>b_cXp5)>_5bxfiNzMZ`IDMJa`RiL;{xxZxT%Ts&ukBY*^EISD=;`Q&Ylg=}z9i}s6rL=>?sc#4Dz zWczX@o-O+MAk=?(!7CDS-dz-4E09LElDaK0OSerUNlPmuyFXLARGxcbC|b=K%-xiCn&0 ziab0#1mQtpr%c&C5B|7Gb2wJUUX1Tvb(J=woZ7YFp?tP-%`o0hRkqi<7$0zvnrbBG z?D_{Ab~MhKvpA*i!WWXjI7I!-4D)^#ONPkt-Yke_Id=GzS^OZ^#i}0J4)miTcU{{{ z1D^tPnHAJ+A6doLyi>-xE8$3>4_(5%Rl3Vy?9ARJNGy-yL#tt{b?% z>);kdEnm@`=iCD1CI+4QmOB`L^U-Hd9bb%s0ThB}OZ_zd;vRI z<+)t)f%y+d00Lj5GRxaMLy+xEjVd31qT@EuBSTINOQ3w#2jaFI;1-yFe!dSL?`59sdd)xPUwV1}?DzX| zp|hscgdj!=&c`PwpY5N`G0z@<*u9voF?VoqFhfvW{18}vw=j*>X9G$lQKM1Y=3rKp^jxR2@ zdL3J-DHFL21}r`J(1P(MxF3vNjc^W5{D>IMf&;4?x1 zh;0kiNEe9ENykiUYTWRISg2Zqaw<`zbj=c9;_3A-e1B$6PDbLCYv290Gg$}8DTXpq zbXL0(es-qRYCk2-mrgX@qAxG89%hzbx2mzDglazIu(;1-NMdGw1Z2oA6fwjt*ToL0J|F1!d1niEpw-pg^iE-`A%Q4QlfBtRg?=Ta3OY zNV6z0$<|j7Q3GY0>*mp@*e6ehBZ(Wge)C3a$0*P08C_f{N0gLsPQ4z#nrsG7r`%Uk zaZjto{CF98i^E5^>^9C9eYU>lvp+P*i6ae&Y5Li@q1gP)Y7}X~jLVri5IR8HK!h=} zz81JbO-x8!v|y?MPv9MQnNsv2#2WJyC=|Isqxn`r}f1%8GM=C z2h~MxU+{*+4W?{YH27S=UYI8K5!G4w9fTc_l$X1P%JL`?bikg(p@(ndNvKFZFPB}t8m|st6 z`TAt?r^k(|Q&8o{9q1B}hARP!0&F=q`-|gSiw#Z04}r1M;1*06q}$*_?4?4Ghd{WU zHeSo=*HqyND{sE1*Cg)(OMdMB+JOqA7OjOUKMdrOG0IjN*ln(b-ZTuaY5qFKTW2a< zBo=>h#Wq<{qO4NZa`vQme>qQcbaNE*sMp)h{dXfr8&z{Jm1qpNQoRy%bQKkI^%t8^ zpUimt!v``UO<;~A-Q;6sm&M{&ghFYj5gQZMHU?k9_k32fVLOS7WXgqONSCM`@O~7? zj*_qc=di#4S#XJ(n^(1B*((q@(F@vXMDo$`=~B$5o`y4WZhyHF^+At6RB7TXb9zEs z`1%O=NGQS=YGb^qu;4=~lVY`L&I`mm|sV%vS4LkfS%E6ISxt z`b^d1f2nta$r?;s()mfgrbrhtk1CSF#IqWD91Lmtd#uYV){iu$p6X}sAmGe6Px2Hh zrFI{z4Q;qpMAqAH8E}*fvXRNYJ%OxvXbuKbqjU^`$a&ImANSh2#iVu_hKz#E(n=Hf z67}9SvpSi2R{n-YtW1S6@X9W&zoHAG2TN8kBT9d7rIZRLTc5spYFW zyW$a7lWZ zw-nI+edApNkrFQ`*|zSyS?BLcEQWQGUPFt7a56vd0O3WY+VP4egL&C{(TgCsHgE?1 zB3|hH^NDDSXg!~ zH~eyP2^GFAz#D6C_r_TK0^$@oKK{-pNQeG{ROSk$G@WhP6+FG^LKBKK{Ylbg9POW1 z-Vy~<@=Bf#{2XOM)RKC0uF;wOsB6gznIo8!MchwOd$Cf+>ZyQtMVV#JI-dWf?H*2V z7f|(*H49|`iD~@Wd0b@z&vBYaB(^nJ0OVA7Jn9Z!_s!*V;lNsRgnN&IJ)Z!DVr*W} zrBT%aa}c@$Brc4hBXv@pXb+`u9^SD2#SdK(W@j{bLZF`B+9_gopzPcPQ1>;kphnEr zS)f^LQwVT8Ws(qQ5ei{@S5n|}LOTqGhEdr($|Jk&b&pur(D9MB?oJsj@SBB{u9^ML zdIj|^OCX-(gHZnZG=59AuDwgL!jT(QVl&oA;OAPl;Hr^{`qtl&bzPW0NRJgB73%MM4B=Lt~pO%WK z-Rh~5Lk5xU?6rvHBE>83H0gu=#~jB^+MgtMSZc`$*VoIt44;Ofc_w~>w$1nlq#@TP zh|sw@E)nq79N&6{hF$gt&@-d2Md#=mzG3z~0_IOd)}o=$F^-Dtn$}3|{Ray1<}sZj z)5*`0o2t3VvRga%bxJDFMkB4^*FxrhuavK?E67UgW(xGsd~h`D(ZlIm0JA|OZ06=; zzh9K6la6u)p4AOFg*86td^Xf(4!^}E8`m9lAm+rfQ8 z4&O)_MaBW-qT8l)fAB>5_+=)B^ATNukRqx?|0U;vKP&55x9rlAM)BmWP35j6in9CW zgYQx`klP1T*!HLeu=%NkUhhSDJwK`yMqOUqU_>+Wb7*W3ogWL;8Y~`mU-L1q4K1n)MV35vIZC-j1qT7Azf4PcJQ=*Y4_dHLyv2J2X%R- z(=(9^lD^8I5kY18bWzOntx-=rtplo!21mM8z?x#?Dj=b?@W702EaQUJ**X}pt$%BK zc$4EVI`v^SbJ2#lr+KXDj7|q)g6I;AP5t<@`L-p@KCq3kKPPG!L4Ilue__i)>I5nb zH&Er>5Y4k`d0Y2Jfz$GbkCV5zq++vF*7-eb;G5y>P?SedJBCl z>j}2uoTi) z-UhA9gJuB~}# zmSl=*5q+o*XSf#mvt|Qb#u3^APD@%*X4hbnv#3o>*tKrb2Eia%uWSThs66d`f>P7E z8amt6!=R6Xfs8Wj;VMFXLj$sDWtefhB4%jY;{aa_@JOF#-QdY&9eP=O3R*e=O6fmD zlMW(kvaH%po)a&q4a9ydzQJ?V2x*fg?$-A7zLDd6*uPH+D{>fq8B^YWHk?)Ak0=nf zGUBd-0$YKcWbDe&X^SeVl}-lQs52#>RbipaP^E;fU2ezgC}z>y5H4R|${F`hA}PHa z?*)oC<)Nd@xct{29^KTB`{9e=^~80P^m_gP{5}kgg2r)0LUVrXxuD3*(KKa<4Ls+U zokdxBzR{(Mj$4F079Epdum@x;CzgaHyh#NM?IS#WH$_n=gVPlGh73$i^cC@$N4ZUx z<#V=(M=aaGVZ14C+%|KG@iYoQm9KB>Vz2Oy)&OOAEYyCdXt&THGZckQt6m9d;yuA( zq0@J;)Gac7;c4|VFJA)T5%qVU%^QQ(*KHn)_?k4JO(!lzdVU8wYj`B>c)2kvqOVFP ziBd`1#Bg)!`bFGeQVt#O=zF}B#&M9iv_yXrqtnS#D}of0`eP|(?=#!={O@%QTE>#* zjzO$I$r{AHZ~hE8W15026AB&te1v?-oeoU2PJ+I?2Vv*Gx;-V59iHOJfF|MPo#0S_ ziadys;rg`7A8?WV18{*GzVv`m%)RoY?UAL@M{%wnoERT|g<|f&8q&34WQX5jLJsiR zV>G*A0}tBV+#JaeN^nu$ypdqQxqtAq1Rbjkavv2u0@2x$U=$y8pBgw4c`-67C(wI8 z8?YAVq5@H)#Hs&p6#Xxr{+EwHgVp~TgZ{6|?th>Cta{Ia+#inbWzFE)|6tgr)BnK! z|A!)r{|GXH)8)3e-=e9Ii##0NSGw%1H23mD{sw`r()fT9)DA~|kum+BCnhFTjTz_4 zELOU}atUnOmCq;9gvOo4$*a(>?6K_0?35#aM)N56-EwaPiI^O5vSs+DNAzD7HRz;h zUi)lUSJ!_LP+3`deeF}*X8BL2y29yy67o+6@5!s})!F4`Wqye7Q*@1o(yObNDDg{_ zf#9Qm750Z8`UlAWFG2`C5^GNr@zo(1-$s%HqQawwgKA(G9$Z#$1ioSh|!?px>sYfcW8mf zCpC!N+}spnzYB_$^FDNH&+qm)n?}SX=$c_4mUwp!zoXColP6WeWK_u7!oBk6ovjPP zM)%gv0-D}A<+B?8P&V~4f&rQ5dxhdKZqM{dPfZn`+lAJ!AK(1ou8qmA5V3Mi6gW++ zYAbv-srr`j2l2vy&@#fRzy+T?bfcSuOjRU$~jIPBK2_Qp&y(XR`+L z_^Lplw+(Ckpc+`ffjSSVg}2z^OyDTH8@8=-NMveHiRMa8tX9|{rClXM3`)30*JDO6 zYVFm8;2q_meR;Nu%n-6GVBjR3D^DpmFl4YC22RPfC0aPf;ayk^^X zRfbB;P&aj9qpT9M*8mZc3-P+neDK- z*vql{uCG)U4(2r?%{5OsvJADGXA9G7^BRY9_4bCxG>y}fbU&2_5082));VAaP_=3M zHVQ8!t9zG*xBZyvy|iUJvJb_zoxgCmw6YSDDM~5C8BHz$2x)Ipj#k;OM)(9*a1GT^ zRoFj)0FAy>p%Mj0RDV$z)S(w^W9Ba9+*{i#dak0<4OUWYQ2WHx98YcD4_)=2%yvDM z4~5JvmRIRS3i@$*V}g(uI5tK07fe3+4rV(FRK9wxAM&;bhHE#@YJe+DUeRB^uI2$M z7d;2*8xhbLF(X~e=!5Z@iQ4O#A)^JrSYk~Bf(_4dE)8pn<60SMTgHIM@_|36fiG$r zXBz&_`VCgNU$y)-z@syCR%2x4W@+VSP4afF(P1Hs^X@36LxY)Ue0;pBq!lw zM-rP|CcqO1lj4@27R318!!mkT3jgp&5R$7GuXYsC@_A;Af;C@ z)|}fH$gD>`)2!5bQ$@w`HU?_gkU(R@VvO*){+((&9UGsRn3!CcoX`vr&FjX!0hV%h zSJYPkA@L|@DKj8`>%5rdsMTJ|GJx**qg5fGqie_rJtd6=g;b-XCB+@{YU-H@S!qdW zX&VEcGjx=I)X{kyR@Nh4KE<5@e>~0pFw5;unNh7TksS_(Q89vy=5&MdI%Ai5iCRg->j;rrnCG_Lzyc+xuv=5C`TY|#Z7%MM=B-_z z%YIi5QL<8nu^sDxGy+tXb0Y$P6~I-At;dNiTKTB**6HUYZh%l$cO#2Acv7@7_5u$5VLLAh^#HFmWkh#*bgc`ZJO9B;~d^3eABbCdM#!~AvL`rt_u=Ej(Q5j zXR30(r)-A{C2ZPl#zxbNyZcS4N9DQ}@J-;E0RI*21ny$IaMr*xv}k?oV_mmFq@7iI zRs#Ul?k0#Y)R5mU&TMdOrn_;Hp1k%qsjObE?HY77)Lu~8oI9W8n~8j=@@SoV(}LI= zmYIL^&h-HAT?%9%*(3jpU9c$H)*>Rg51r!Yj@x~&=zV89{3X$=z(*Y=Ue2;+0>3*% zd_ScKXbWDv@?RjawnjWNP`f?YO}oAh@34AKCo)GXI1e7i>y#65-byI`-aywQ2ddm! zQF#x@)Fw;DTkPr$wBtMl!oh;8d2ZuTmeKeChtt;}#(JSotT@M%jrC{HR$xq(6H-J# zfP;f05LD2foox8wm%|3Gmi5p@K8J?F-mU)vajk(|a23P@7o~K2$Ni&KHZ>*lVX#+A z^SJ?jCpbgG#lr7{>cWqZUr$1<;hkT5cBhcF$2kknh%;Zi0!xNhz4dN5r-&?q7LMN> z3u3(XV0{t6vJjKVI$B3>(bf&APx z6^c5xXQgVy){HNhcXscD&g@uNPNlO}ZqiRu@Y}Qu-I23vHBN0>9!uF7FNUBl-;F)& zr=+llEwiz=Bi8nx%)27zOP}3&9ZM~kYbVWIQ5aq9arKwayb=LsF|!Sz%}~J{B^Ghf zMcCuB9YmeoK(E)!l&V>o(MP&innw3o|3!l>VFOBqU)E)`4hmFGaNPntpM_OAJU<&u zyt7!lqpyWF1fbh36@OMO;1RkCyc)q%k9b1ny7nIUZPV=FMt%Qbc|T?eaRA9)CtZ$^Jqgu(A7Ep zi*2K#J^TTcM3vY|AGR+Y`m=)t*9&S<zo_B(wY&8k!AZD1KA_WvU#@D0^+B?c(uNG6dvieIvdxMI@ zu1Q)yoW&8|alOoDNI$9bA4~BC3snkHwA`rN|<6NdQfoS^~H$M^?<_GL-jhteF)Xn;uGvGUVqY(UZ4q@Ws~NFO!Ois(;MIYJgN?^1 zQ``PGkIT}`>s?J%q==mL7jMq)q9VYG7b5HLYOlRT=+xBI*^=jqXUCOcOg|rRl=2td zk-)uzKcSRZLq93Fxz(Rf21t#Y1K~Tj&zp|CexCJv6NvP6f?*BbmeCw_-%7zPXG^_C zP!`j$h+mzdGK0oie^6U$6G*bkGLVhz2L}%}@YZ2MECq-_p2s8i+ zFG>hsJ^6M^J7zv_#|?v>k+%{RA7V92;EUfC)j$wsi=W_3ibu7FK{Q1$HLsL#_DHp^mG#kR|R~T8(pTThz zsHQUc%G~iI;FG-n$<_TRo@S?PCp3ixZGy#b>~EaHIuL!ueV3T&~s^2Np=%U zBg-!)Y$nAdq?W`{S6rAxv6D`L2%vDydyw@g)DdnVIaF$1j{tc@mxahi%`1~2Vwci( z2kwe%g)?II<*E?_#~xs&(A1W>bg%kU8miVZSeDf7kfifCf@Ks|dx$MibwU(E{!pr_ zHrR$gAxgzYk?z@&LDF%%b^gWKWYao?4X;2qtpIUZg8-}eCdo|(6*wP*+sA-|J;H81 zhg(`yVp{u%BhGX#3y0gAnS8P7!uoT}Z0)w2J$xfHt?TKTS-RMIk>`Dfu&hFCOc(`H zE!{>RUrp~3oRTk&pmn{sZw>QxFh$VBMWvMX2vNbqN}=xM$?cl|ka_seMKE)0l={w{;m9RwDHldtb-o${e3 z>CKL2`R<)vMlEpgaIN3}YGH*_r}|B1DNjHe#V0i(G#p3QZK#}(To`0T!~7oi&4D|v zH@Fl-FfPdNqg?_}^nio-A7%qx`(y{lvwXH-Drd7xL}~-<%1-8GN7Dibw8MNDt%H4^ z@8@pzvFnGRkvgoj=I#%9aQu6L@5QQV+t^bynL#Rwt$?o-_9h`IUeo;UzVZZ;380bm zYpt(OyrvTSr(yuzFPckWL?KoBJ5;gJ#I zhvfh8LigMRiZZ~wKl%l_{Ai}5-FAvqExY*584#{rNMt>F&td#&vp59q=H*1!p`dqB zczC#OomJH2mK)T-qUHy)m%K0|hI{@c{rL+E-DHClgQh0~0k2U?o;)JTY9%Km9p^H>zBdJmDU30+ zG0i%81@|-#Bj39C^5PXid;If(Z^Bpk?dLVXjun4&HGD0842|S%Qk9P- zx{gA6u;jCB=FSnl)o;eA#wTb2slyKdvS9AEGYX{Fu#JNQ14_v@zB(o1>({V19jDX! z(%-Pi@XEYW7lG<(cE>xwR=X|S6K0;Hv+$UVI5xV!4e$jHE=A*R^I zAlsu_>%zbav!1{d=HI@522B&0d_rB%0kgxq;X&v+@&%_3-@DtJdJ>0J{#g%RSY|a%9zwMt7mkR&t2?{lZql=WV#tYNa$#WHEJ*5J4=o%1xeX>U(A=y>N z9TNVx*By9=+Wx27_ftHcCkG?xX;zYHED#!5dpBF!YsO*imMSIn?S8RvN4;*8Qc7ih zUbBiW#&OPTZWg6oLdWzK<>loVm}*8wMmjq0kZH(z^iValwe{Iw)>5o^?^HPX9cMfW zG+k(PsA|6~g;|Jx{P}rui+z=4UF2o%HzAs6TvIk;9H}AMp^KD`>w?t&4VR>Y#3M>X zPK+KqNxgtX(@9abV^R#?X*ML-{6eVejC;* zgI~ul_XGC8uNX{{25j(ntNApZ6SlxnfZ3vfjE_;L=v3Lh?JlsZ7)t$VsSnTc%1y)00Nr?(u zHK|<=H~ZWuT6_a^1yxPPpVeQR)qHf5wwO{?evHZfqrQkZ?|B8c%+&gV7 zB9Z7NXA@)dTGI7t`98`iidnf2C6SUxX`s+4Uw}V(dw6*G`1nk`^nD)=2)02BPZIS| zzNyB%h21q5|Ed8Y+vzs8l>ku}L58!Mx1to*h;-_eLR2v8hXu=tWXn|b(R%#ZA)Ewy z{Y$Jyy;<=MS@g=62nn^cY7GWHk$s4=A>b)*@N;vqQw>`uq14Eh2hV(`{>@@qp3&|? zKjb?oi8Q=M`!geN3Z$DXEG&rW=>!X@T)jW0+U)BWQ{>mjg|2Tf?L(K+$NB~b_v1*_ zaLd`3+1U}nRE18v)X0L{8K!i#JVoBHj~{_gcQlebSJL$J)3uej9c8m?^Rz8MY&fo@ zl#-Id4beZEb%{Zm5tItcxrkAmQr#gBfjVN0?j@Wo#t%k5{;T==A{Ej<3p)Jpc)Y*A z|JAF*b}ul**3qFM^Iol0QFJV4ACreb02;DVQN4Ey6OUZ5TVzyt_mK!UovUxE z)zwv01QZNw4((A~w^~AatT?p&VY0HFtOwKW!{vlwh@6rB{`1mQDm6*m5#-K)>Yq#V z;h&5BQ5=2j&UUK16ytk|Jc?E=He zwl0fW4u71T1wbrF$~7o4C=)@ftGc73m9Q*pya|fu-JoK}@`elB**^x^=|*-M6{{s26!b_#{$`C;WhhvyYe8p*~XJ)?oTc z`;Et{zIW%pNF=iMZhzegt%MlWPBQGsJPz#eL`S4r6gGCj?5zGHu z-sX$*w1tXRLIFsSXdzLla~iburRFq;tCa^M3GVOIXhAoJXzE+<7fm_qo3yczlb-oZ zA>%Up;#EIaS4nQu-cE}bHH$L=)kQ^Hk{^OiI6fv(Y7M42gn0C3wv0_t&?2-o4@>I_ z6nXaS*%o(6jcK|cVN+~jX(``FnxXf*=5P0z@-Vxxlc9N}FkTsj)Y207fgscia^6P` zCnqQ4WJ32|x!NUUjrzh$$boigx8{j9KqTzr^s=@dnB@XvE|epvjH%3jVltVZpa;k^ zXL9A7pFD{byV~RrVXZ7IlzVWSA-&lNDCa9z-em$&X5~W@exJVfD&9p!T%=;i9peq` zkkwmi3odS?sg3x-z|Hp(^A|bzkMEzldrn|Y%BSPw#ogZ6m2f^&eTLbE^@TwbBAtZ& zDou}kKVORol6lqF`C&AIp*g3`Bl~Qd#z-e!4Dpm>JgsW+sShyfm90Q_)*&6OC}DqY=i6C)#| zPQ~A*K1+O_88i*e8rz%}jksFB1 zo9qciZ>@)hzMShuz%ZdHM#Ox%CkP-F+Bh?P-8&Ilr;2gJmV`w|Cw=oZ9CGZ9LVF zO+tdekXsE<(Ek@kE1&sy2OMXsQuJeAKlAqVOk=XGc0*0S1Z*gQgT}X|gO6j>x5E+Z zR@6+w-3v3Ri3?U*1z3jQ4ji4TLUF9|vMN~NA;@qu3Yzxz^3n|jU$HT2e(66Ae6!Yh zeu#>JzVrd>6YlzbaRz(B z;vd%u$^yq) zjzu0nFVp8Btd|rg#SfVcZ)*#*S#*)sqB5Hok$LA{l$XO!E^|Zr$WSKQ<_wXDNSX1A zD1N|OSZlg@quGL)qWQ(TbM#dfpIp&4VDhBa9a1!tJsg9zD$v&Nu4SF3;-j zqnwDF3@_EKzHLA>Z_GpX{vJ-DhwF;!z;_FM?TiPu645w!$0aOBrwbvhcH5%>ydZxMrcTmt?()|mXz)ie%wp1}lwr)eNS6eJ78$)=Z|8?I7t&USedj)*mSyF2f{_;JbyCuf({ ze3%G!z?7EKNkh7Yz>S=q!yh`0?Efq5j_2&DE-|_kCu`-HI72Y}K~POb&Gj+TC}Z0y zT%c?$gckr?LASV^Yctk^+ESHXWHT^Roz9c*wwbNmC3(cL%cGGZP#k~ORRFeES-PTR zXAqnqxWAhxJ+~y;4%VKd>It@HWO_ufX5UVb-^+ibo7{3}G!=RsxofY2I_5eyN_RW1 zv-h=L$!ZZF9i*thUC)aR!}7jWq+P7`=sqzqO`b77|B9dUB0&4|H(%-w{J4n-eyG{- zVf>e!Ax6k5xtAph8?@`cJtT7bs8IKreuJGZVD=Q6`gnZdbb23OG0 z!V1h~QbRk7!3lLRsO=!J_8)HkhWH`0aQj*F^_kjo^Y-Mvy@zuy1se{BAi&3jema1F zIRp;*oxmZ#6FB5|0*Cz0ijzU(@%aA!{{8*k>uCH;I2<-j6Rl=68r|L9jYgw0#yE0l zFc|FY>}+msA_@!u08G=ozP>&mW5&1cVnD>55W)v(BfoP zgG#~g1P=L~z#+dAIOKPJpF_H?>$?8aQT#m|^+Pi_K0Y=KW9mooc)V09ktDg~Ehf=* zT@VBSfFwx-K_FB{La|uvcDwc|P1B`PX-Umx9Wo4~)9L(qonaWB=LJEKBnhEOs;c(; zeE`j^k>z8l2)n zp+FEsDwT4+fD}cUY7dU%JkP`B-fFe#^?JMArYLHGl?1V^n&Fu%Jj`-Pu{0JA2CIvfoNLPeGjJV8x|r+Hg2D41*-eWHOmfr`^se za(j}=)C8pQ4}1dnoerwi z^Ic-B*Xyz@!+i=_mh1KUjKMzwN{2*I6a;~$X$->}jRtHHXqskORuo0&I{AElZ*NZ! z1iNurKDog#{=9A&Mx|2WIF94EN~K~L2FtSkDH8zNEZ6OJPft$)0PsnKP!vUyWVKp# zu5)*HcXoCLk0nV$_1#k_6ex;#C4?yb>MNx{P&@|2SJgjbWxg1H7mzS4r zGrQePpe+q(n)ccNy9Ywj41whxf_n!%&u223TrQW*W;IQtC<^u~yKDmRLvzU$2?idsf_S%)U~Gq$$2&d<+fS+3P;Fq0$_36^DZxtyz6M)malO<;QW z0>sreK@e`c{eap0zF+YEH3EnHPT-K=2^{h}fkS@BkwcM4WHcIi9m+q*Xf#?`nNv<< zbN^{ zYin!U+uNpTA`Hg+h@uOWyR|9vn`6X7II%6hbnypWwc15Wo8yqL-59P&GX bLw@H!(;hU=yU2xM00000NkvXXu0mjfU4dG} literal 0 HcmV?d00001 diff --git a/Documentation/Images/RedirectAllowedExcludefields.png b/Documentation/Images/RedirectAllowedExcludefields.png new file mode 100644 index 0000000000000000000000000000000000000000..2753e731520ae01e6589b409ba8393493f8da3fe GIT binary patch literal 29785 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 literal 0 HcmV?d00001 diff --git a/Documentation/Images/RedirectDetective.png b/Documentation/Images/RedirectDetective.png new file mode 100644 index 0000000000000000000000000000000000000000..f7ee03c1c85402d63bb01e4bb6d4a705b11df6ab GIT binary patch literal 21032 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 literal 0 HcmV?d00001 diff --git a/Documentation/Images/RedirectEdit.png b/Documentation/Images/RedirectEdit.png new file mode 100644 index 0000000000000000000000000000000000000000..82097aecfda506eddda28912c714616088e8775f GIT binary patch literal 35225 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 literal 0 HcmV?d00001 diff --git a/Documentation/Images/RedirectList.png b/Documentation/Images/RedirectList.png new file mode 100644 index 0000000000000000000000000000000000000000..c798b856c71293cf4e8407c59a5f625056c4f583 GIT binary patch literal 30192 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
!95bT91;2@9-qFDJXZ}EKp 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 literal 0 HcmV?d00001 diff --git a/Documentation/Images/RedirectRevert.png b/Documentation/Images/RedirectRevert.png new file mode 100644 index 0000000000000000000000000000000000000000..fab58ec1cb3a9710376501c1bc8ceeac58cd7430 GIT binary patch literal 16406 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 AasU7T literal 0 HcmV?d00001 diff --git a/Documentation/Images/RedirectsEditStatistics.png b/Documentation/Images/RedirectsEditStatistics.png new file mode 100644 index 0000000000000000000000000000000000000000..27f960b6236a35a4ffe8dab2ca76d3c7b9aca1eb GIT binary patch literal 12651 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 S`sa)gpnu;;yG+v|;y(a2K+S3Z literal 0 HcmV?d00001 diff --git a/Documentation/Images/SystemReportConflicts.png b/Documentation/Images/SystemReportConflicts.png new file mode 100644 index 0000000000000000000000000000000000000000..cd2a4d648558798a8e0cf032604e94e950bce8fe GIT binary patch literal 25542 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 pZ0`T9i~iSV`A`__ + license. + +:Rendered: + |today| + +---- + +This extension makes it possible to create manual redirects, list existing +redirects and automatically create redirects on slug changes. + +---- + +**Table of Contents:** + +.. toctree:: + :maxdepth: 2 + :titlesonly: + + Introduction/Index + Installation/Index + Setup/Index + Usage/Index + BestPractices/Index + Events/Index + KnownProblems/Index + Basics/Index + +.. Meta Menu + +.. toctree:: + :hidden: + + Sitemap diff --git a/Documentation/Installation/Index.rst b/Documentation/Installation/Index.rst new file mode 100644 index 0000000..5826621 --- /dev/null +++ b/Documentation/Installation/Index.rst @@ -0,0 +1,56 @@ +.. include:: /Includes.rst.txt + +.. _installation: + +============ +Installation +============ + +This extension is part of the TYPO3 Core, but not installed by default. + +.. contents:: Table of contents + :local: + +.. _installation-composer: + +Installation with Composer +========================== + +Check whether you are already using the extension with: + +.. code-block:: bash + + composer show | grep redirects + +This should either give you no result or something similar to: + +.. code-block:: none + + typo3/cms-redirects v12.4.11 + +If it is not installed yet, use the ``composer require`` command to install +the extension: + +.. code-block:: bash + + composer require typo3/cms-redirects + +The given version depends on the version of the TYPO3 Core you are using. + +.. _installation-non-composer: + +Installation without Composer +============================= + +In an installation without Composer, the extension is already shipped but might +not be activated yet. Activate it as follows: + +#. In the backend, navigate to the :guilabel:`System > Extensions` + module. +#. Click the :guilabel:`Activate` icon for the Redirects extension. + +.. figure:: /Images/InstallActivate.png + :class: with-border + :alt: Extension manager showing Redirects extension + + Extension manager showing Redirects extension diff --git a/Documentation/Introduction/Index.rst b/Documentation/Introduction/Index.rst new file mode 100644 index 0000000..56c5904 --- /dev/null +++ b/Documentation/Introduction/Index.rst @@ -0,0 +1,71 @@ +.. include:: /Includes.rst.txt + +.. _introduction: + +============ +Introduction +============ + +During the lifetime of a website, the URLs of pages often change. If no +countermeasures are in place, users will attempt to access pages that no +longer exist when browsing your site. Typically, when this occurs an error page is returned. +This is inefficient and impacts the user experience. When multiple missing pages or 404 +/ 410 HTTP status codes are returned, the overall SEO ranking is negatively affected. + +Changing URLs can have multiple reasons, sometimes the name of something changes +and the URL should reflect that or pages are restructured on the site. + +There are many reasons as to why URLs are changed. This can include a restructure +of the site's pages and also occurs when the name of a page is changed. +and the URL in turn changes as well to reflect this. + +HTTP redirects act as an important measure to guide users (and bots) to new +pages. This often happens in the background without the user noticing it +because the browser will automatically resolve the redirect. +This works similar to a forwarding request when you move house and your address +changes. + +For more technical information on how redirects work, visit +`MDN Web Docs Redirections in HTTP `__. + +For more information about the types of redirects, see +:ref:`HTTP status codes ` + +.. _introduction-what: + +What does it do? +================ + +The TYPO3 system extension EXT:redirects handles redirects within a TYPO3 site. + +Features: + +- Manually create redirects in the backend. The redirect information is + stored in the :sql:`sys_redirect` table. +- View and edit existing redirect records in the redirects backend module. +- Automatic redirect creation on slug changes (based on site configuration). +- Console commands to check the integrity and cleanup existing redirects. +- System reports that display information about any conflicting redirects. + +.. note:: + + EXT:redirects does not handle redirects created via page types "*Link to External + URL*" (`pages.doktype=3`), "*Shortcut*" (`pages.doktype=4`) or redirects created + within the web server (e.g. :file:`.htaccess` or web server configuration). + +.. _conventions: + +Conventions +=========== + +Visit the :ref:`basics` page found at the end of this document for a general +definition of terms. + +When describing parts of the user interface, we use the :guilabel:`gui label` +to mark texts within the UI. + +Common names are formatted in *italics* (though this is not used everywhere to +ease readability). + +Sometimes the topic of a paragraph is marked in **bold** to ease skimming of +pages for relevant content. diff --git a/Documentation/KnownProblems/Index.rst b/Documentation/KnownProblems/Index.rst new file mode 100644 index 0000000..70db8b0 --- /dev/null +++ b/Documentation/KnownProblems/Index.rst @@ -0,0 +1,25 @@ +.. include:: /Includes.rst.txt + +.. _known-problems: + +============== +Known problems +============== + +.. _usagePitfallsConstants: + +Problem with constants in LinkHandler TSConfig +============================================== + +It is important, that the `storagePid` is hard coded in the LinkHandler Page +TSConfig, because using constants, e.g. from the site configuration, won't work +here. :ref:`More details ` + +.. _known-problems-other: + +Other known problems +==================== + +For more known problems, please refer to the +`open issues for "Redirect Handling" +`__ diff --git a/Documentation/Setup/Index.rst b/Documentation/Setup/Index.rst new file mode 100644 index 0000000..2b6ca3e --- /dev/null +++ b/Documentation/Setup/Index.rst @@ -0,0 +1,380 @@ +.. include:: /Includes.rst.txt + +.. _setup: + +===== +Setup +===== + +The redirects extension requires no extra configuration once it is installed. +However, it is recommended to familiarize yourself with +the settings and commands outlined in this page. Depending on your site and how +editing is handled, changes in the configuration and regular maintenance may be +required. + +.. _site-configuration: + +Site configuration +================== + +The core comes with the following site settings for redirects which can be +configured per site. + +Configuration via backend module +--------------------------------- + +The redirect settings can be configured in the backend via +:guilabel:`Site Management > Settings`. + +Configuration via YAML files +----------------------------- + +Sites using site sets (TYPO3 v13+) +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +For sites using site sets, add the settings to +:file:`config/sites//settings.yaml`: + +.. code-block:: yaml + + redirects.autoCreateRedirects: false + redirects.autoUpdateSlugs: true + redirects.redirectTTL: 0 + redirects.httpStatusCode: 307 + +Alternatively, you can define these settings in your site package at +:file:`mysitepackage/Configuration/Sets/mysiteset/settings.yaml` to provide +defaults for all sites using this site set. + +Legacy site configuration (TYPO3 v12 and earlier) +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +In legacy installations without site sets, add the settings to +:file:`config/sites//config.yaml`: + +.. code-block:: yaml + + settings: + redirects: + autoUpdateSlugs: true + autoCreateRedirects: true + redirectTTL: 0 + httpStatusCode: 307 + +.. hint:: + In older installations, the file is found in + :file:`typo3conf/sites//config.yaml`. + +Available settings +------------------ + +The following settings apply to **automatically created redirects**. +TYPO3 comes with working defaults. It is not necessary to configure these +settings if you use the defaults. + +**autoUpdateSlugs** + Automatically update slugs of all sub pages (default: ``true``) + +**autoCreateRedirects** + Automatically create redirects for pages with a new slug (works only in + LIVE workspace) (default: ``true``) + +**redirectTTL** + Time To Live in days for redirect records to be created - ``0`` disables + TTL, no expiration (default: ``0``) + +**httpStatusCode** + HTTP status code for automatically created redirects, see + `MDN: HTTP Redirections `__ + (default: ``307``) + +The `httpStatusCode` does not affect the default status code for manually created +redirects. This can be adjusted via TCA +:php:`$GLOBALS['TCA']['sys_redirect']['columns']['target_statuscode']['config']['default']`. + +.. versionchanged:: 12.1 + Since TYPO3 v12.1, automatically created redirect records are stored on the + configured root page ID of the site. Previously, they were initially stored + on the top root page or later on the changed page. + +.. seealso:: + + The `settings` in the site configuration are generally explained in + "TYPO3 Explained" > :ref:`t3coreapi:sitehandling-settings`. + +.. _console-commands: + +Console commands +================ + +As for commands in general, it is possible to execute them via the command +line or via the TYPO3 scheduler in the backend. +Please see the general information about this in "TYPO3 Explained" > +:ref:`t3coreapi:symfony-console-commands-cli`. + +We explain executing the commands from the command line here, it is recommended +to automate regular execution, e.g. via cron. + +.. _redirects-cleanup: + +redirects:cleanup +----------------- + +The CLI command `redirects:cleanup` can be used to periodically cleanup existing +redirects under given conditions. + +Use `-h` to see all options: + +.. tabs:: + + .. group-tab:: Composer-based installation + + .. code-block:: bash + + vendor/bin/typo3 redirects:cleanup -h + + .. group-tab:: Legacy installation + + .. code-block:: bash + + typo3/sysext/core/bin/typo3 redirects:cleanup -h + + +**Example 1:** Remove all redirects with less than 50 hits **and** older than 30 +days. + +.. tabs:: + + .. group-tab:: Composer-based installation + + .. code-block:: bash + + vendor/bin/typo3 redirects:cleanup -c 50 -a 30 + + .. group-tab:: Legacy installation + + .. code-block:: bash + + typo3/sysext/core/bin/typo3 redirects:cleanup -c 50 -a 30 + + +.. hint:: + + The `-c` option does nothing if the + :ref:`hitcounter feature toggle ` is not enabled. Be careful + when using the `-c` (= `--hitCount`) option. It is advised to combine it with + `-a`, otherwise this will also cleanup redirects which were just created and + did not have the possibility to accumulate any hits. + +**Example 2:** Clean all redirects for domains foo.com and bar.com older than 90 days +and with hit counter less than 100 which start with the source path `/foo/bar` +and have a status code of 302 or 303. + +.. tabs:: + + .. group-tab:: Composer-based installation + + .. code-block:: bash + + vendor/bin/typo3 redirects:cleanup --domain foo.com --domain bar.com \ + --age 90 --hitCount 100 --path "/foo/bar%" --statusCode 302 --statusCode 303 + + .. group-tab:: Legacy installation + + .. code-block:: bash + + typo3/sysext/core/bin/typo3 redirects:cleanup redirects:cleanup --domain foo.com --domain bar.com \ + --age 90 --hitCount 100 --path "/foo/bar%" --statusCode 302 --statusCode 303 + + +.. _redirects-checkintegrity: + +redirects:checkintegrity +------------------------ + +The checkintegrity command checks existing redirects for conflicts. A typical +conflict may be a :ref:`redirect loop `. In this case the source +and target point to the same page or the redirect loop affects a number of +redirects, each redirecting to the next and looping back to the first, e.g. +`/a => b, /b => /a`. + +.. warning:: + + Currently, there are known problems where the checkintegrity command + may report false positives. This can happen if additional routing enhancers + / decorators are in place. + +Example usage to check all sites: + +.. tabs:: + + .. group-tab:: Composer-based installation + + .. code-block:: bash + + vendor/bin/typo3 redirects:checkintegrity + + .. group-tab:: Legacy installation + + .. code-block:: bash + + typo3/sysext/core/bin/typo3 redirects:checkintegrity + +Check only the site mysite: + +.. tabs:: + + .. group-tab:: Composer-based installation + + .. code-block:: bash + + vendor/bin/typo3 redirects:checkintegrity mysite + + .. group-tab:: Legacy installation + + .. code-block:: bash + + typo3/sysext/core/bin/typo3 redirects:checkintegrity mysite + +This will output one line per redirect conflict. The output may look like +this: + +.. code-block:: none + + Redirect (Host: *, Path: /test-1) conflicts with http://mysite/test-1 + +You can now search for the affected redirects in the redirects module, e.g. +by filtering with *Source Path* `/test-1`. + +.. _editor-permission: + +Configure editor permission +=========================== + +By default, editors (without admin privileges) cannot access redirects directly and they cannot +revert automatic redirects. This can be problematic, because the notification +with the option to revert redirects and the notification that they were reverted +appears regardless, even if an editor does not have access and the redirects +are not reverted. + +In order to make **reverting redirects** possible for non-admin backend users, +configure this in the backend group :guilabel:`Access Lists` tab: + +- Activate :guilabel:`Redirect [sys_redirect]` in :guilabel:`Tables (listing)` +- Activate :guilabel:`Redirect [sys_redirect]` in :guilabel:`Tables (modify)` + +In order to give editors full access to the **redirects module**, give them +access to the :sql:`sys_redirect` table as outlined above and configure this in +the backend group :guilabel:`Access Lists` tab: + +- Activate :guilabel:`Link Management > Redirects [redirects]` in + :guilabel:`Modules`. + +.. warning:: + + It is recommended to only give trusted and experienced backend users access + to the redirects module because they will have access to all redirects for + the entire installation and may unintentionally wreak havoc on the site. + + Especially problematic can be redirect loops because they result in + broken pages, but these can be detected, using :ref:`redirects:checkintegrity + `. + +By default the fields *Source Domain*, *Source Path* and *Target* are enabled, +the rest are excluded fields, which must be enabled for the respective backend user +group in the :guilabel:`Access Lists` tab > :guilabel:`Allowed excludefields` > +:guilabel:`Redirect`. + +.. figure:: ../Images/RedirectAllowedExcludefields.png + :class: shadow + + Allowed excludefields + +.. _hit-counter: + +Hit counter +=========== + +The hit counter can be activated via +:ref:`Feature Toggle `, either in the backend in +:guilabel:`Settings` > :guilabel:`Feature Toggles` > +:guilabel:`Redirects: hit count` or in the +configuration file :file:`system/settings.php` or +:file:`system/additional.php`. + +.. code-block:: php + + 'SYS' => [ + 'features' => [ + 'redirects.hitCount' => true + ], + ], + +This feature toggle is disabled by default, because it comes with a small performance +impact that requires additional SQL :sql:`UPDATE` queries. + +Every time a page is accessed the hit counter will be incremented. Based on the +hit counter, a delete policy for unnecessary redirects can be defined. + +Visit the :ref:`redirects:cleanup ` with the option +`-c` for more information. + + +.. _system-reports: + +System reports +============== + +The redirect conflicts will also be shown in the system report, available +via :guilabel:`Reports` > :guilabel:`Status Report` in the TYPO3 backend. + +It is required to run `redirects:checkintegrity` regularly, so that the results +can be displayed in the report. +The information is stored in the registry (:sql:`sys_registry` table in the +database). + +.. figure:: ../Images/SystemReportConflicts.png + :class: with-shadow + + Redirect conflicts in system report + +In case :ref:`redirects:checkintegrity ` was not run +within the last 24 hours an additional informational status will appear in the +report: + +.. attention:: + List of conflicting redirects may not be up to date! + Regularly run the console command `redirects:checkintegrity`. + +This can be configured in the extension configuration with these 2 settings: + +* :ref:`showCheckIntegrityInfoInReports ` +* :ref:`showCheckIntegrityInfoInReportsSeconds ` + +.. _extconf: + +Extension configuration +======================= + +**Reports** + +.. _extconf_showCheckIntegrityInfoInReports: + +.. confval:: Show information in reports if checkintegrity was not run. + + :Field: showCheckIntegrityInfoInReports + + Show informational status in the reports if redirects:checkintegrity was + not run within the last 24 hours, or rather the number of seconds indicated + in the setting + :ref:`showCheckIntegrityInfoInReportsSeconds `. + +.. _extconf_showCheckIntegrityInfoInReportsSeconds: + +.. confval:: Number of seconds to consider last checkintegrity report. + + :Field: showCheckIntegrityInfoInReportsSeconds + :Default: 86400 (is 24 hours in seconds) + + Number of seconds which must pass until the informational message is shown + about checkintegrity in the reports. diff --git a/Documentation/Sitemap.rst b/Documentation/Sitemap.rst new file mode 100644 index 0000000..c809724 --- /dev/null +++ b/Documentation/Sitemap.rst @@ -0,0 +1,11 @@ +:template: sitemap.html + +.. include:: /Includes.rst.txt + +.. _sitemap: + +======= +Sitemap +======= + +.. The sitemap.html template will insert here the page tree automatically. diff --git a/Documentation/Usage/Index.rst b/Documentation/Usage/Index.rst new file mode 100644 index 0000000..0afdc2a --- /dev/null +++ b/Documentation/Usage/Index.rst @@ -0,0 +1,372 @@ +.. include:: /Includes.rst.txt + +.. _usage: + +===== +Usage +===== + +.. _usage-redirects-module: + +Redirects module +================ + +Access the redirects module in the TYPO3 backend under :guilabel:`Sites > Redirects`. + +.. figure:: ../Images/RedirectsMenu.png + :class: with-shadow + + Open Redirects module + +.. _usage-redirects-module-list: + +List +---- + +.. figure:: ../Images/RedirectList.png + :class: with-shadow + + Redirect list + +You will see a list of the existing redirects with the following columns labels. + +#. **Source Domain** +#. **Source Path** +#. **Target** +#. **Count**: Number of "hits" (only if hit counter is on) +#. **Last Hit on**: When was the most recent redirect "hit" (only if hit + counter is on) +#. *Action buttons*: View page, edit, disable and delete |action_buttons_image| + +.. tip:: + + Hover over the text to see the link markup (underline) and a tooltip. + +It is also possible to **sort** by clicking on the :guilabel:`Source Host` or +:guilabel:`Source Path` column headers and changing the sort order by clicking +again, as also done elsewhere in the backend. + +By clicking on the *Source Path* of one of the columns or on the pencil edit +icon |edit_action_image|, you can **edit** the record. Clicking on a link in the +*Destination* column, should open the link target. + +The :guilabel:`+` sign on the top will open an edit form to create a **new +redirect**. + +It is also possible to **filter**, e.g. by the *Source Path*, *Status Code*, +*Creation type*, *Protected* or only show redirect records which were "*Never hit*" +(see Information on :ref:`Hit counter ` which must be explicitly +enabled via Feature Toggle). + +.. _usage-redirects-module-edit-form: + +Edit form +--------- + +When creating a new redirect or editing an existing one, the edit form will open. + +A redirect generally consists of these 2 parts which are separated in the +edit form: + +#. A **source** part (host, path, query parameters) which is matched against + the URL. If it matches, the redirect is applied +#. A **target** part which defines where the redirect should redirect to and + some additional parameters like the HTTP status code, whether to force HTTPS + and keep query parameters + +Also, the redirect has some additional parameters that are specific for the +redirect record but not relevant when generating the redirect, such as the +:guilabel:`Protected` field. + +Admin users will see the respective database fields from the table +:sql:`sys_redirect` in square brackets (e.g. +:guilabel:`Source Domain [source_host]`) next to the label if in debug mode. + +Non-admin users may not see all the fields. By default *Source Domain*, *Source Path* +and *Target* are enabled, the rest are exclude fields and must be enabled in the +backend group permissions, see +:ref:`backend user configuration `. + +.. _usage-redirects-module-edit-form-general-tab: + +General tab +~~~~~~~~~~~ + +.. figure:: ../Images/RedirectEdit.png + :class: with-shadow + + Edit redirect + +---- + +**Source:** + +.. confval:: Source Domain + + :Field: source_host + + It is possible to select one of the domains from the site configuration or + use the wildcard (`*`). In this case the redirect applies to all sites! + +.. confval:: Source Path + + :Field: source_path + + Can be an actual path, e.g. `/path`. For URLs with different entry points + for languages, you should use the full path, e.g. `/en/path`. + :ref:`Regular expressions ` are possible, but then + `is_regexp` must be enabled. Regular expressions must be enclosed in + delimiters, e.g. `#^/path/([a-zA-Z]{1}[a-zA-Z0-9_/-]+)#` or + `/^\/path\/([a-zA-Z]{1}[a-zA-Z0-9_/-]+)/`. + +.. confval:: Respect GET Parameters + + :Field: respect_query_parameters + + If on, matching is also performed on query parameters. If off, matching is + only performed on the path. + +.. confval:: Is regular expression? + + :Field: is_regexp + + Evaluate the Source Path as regular expression. + +---- + +**Target:** + +.. confval:: Target + + :Field: target + + The redirect target, can be a + + - path, e.g. `/features` + - URL, e.g. `https://example.org/features` + - page ID or page URI, e.g. `t3://page?uid=1` + - file URI, e.g. `t3://file?uid=1` + - path with reference to + :ref:`regular expression capturing group ` if the regular + expression feature is used with e.g. capturing groups in Source Path, e.g. + `/newpath/$1` + +.. confval:: Status Code HTTP Header + + :Field: target_statuscode + + The :ref:`HTTP status code ` that will be sent to the + client. This is 307 (Temporary Redirect) by default. + +.. confval:: Force SSL Redirect + + :Field: force_https + + When redirecting, use HTTPS when constructing the target URL. This will even + be the case, if a full URL is given as target (e.g. + `http://example.com/features`) or if the entry point of a site uses HTTP, so + make sure your site supports HTTPS (which is recommended anyway). + +.. confval:: Keep GET Parameters + + :Field: keep_query_parameters + + When redirecting, add query parameters of original URL (with possible + changes) to the target. By default, the query parameters are omitted, so + source URL `https://example.com/features?abc=1` would be redirected to + `https://example.com/all-features`. If there are already query + parameters in the target field, these are used instead. + +.. confval:: Protected + + :Field: protected + + This does not affect the redirect itself. It protects the record from + automatic deletion (e.g. with redirects:cleanup). + +.. confval:: Creation Type + + :Field: creation_type + + This field allows to differentiate between redirects that are created + automatically when the slug of a page is changed and those that are created + in the backend module by editors. + +.. confval:: Integrity Status + + :Field: integrity_status + + This field hints about a broken redirect, for example, if the page references + to itself. + +.. _usage-redirects-module-edit-form-statistic-tab: + +Statistics tab +~~~~~~~~~~~~~~ + +.. figure:: ../Images/RedirectsEditStatistics.png + :class: shadow + + Statistics tab with hit counter + +This tab is only available, if the hit counter is enabled. Here you can disable +the hit counter for a specific redirect and also see read-only statistics. + +.. confval:: Hit Counter + + :Field: disable_hitcount + + Disable the hit counter only for this redirect. + +.. confval:: Count + + :Field: hitcount + :Editable: read only + + Number of hits for this particular redirect. (How often was the page + accessed which triggered this redirect?) + +.. confval:: Last Hit on + + :Field: lasthiton + :Editable: read only + + When was the last hit on this redirect? + +.. confval:: Created At + + :Field: createdon + :Editable: read only + + When was this redirect created? + +.. _usage-redirects-module-edit-form-access-tab: + +Access tab +~~~~~~~~~~ + +.. confval:: Enabled + + :Field: disabled + + If disabled, the redirect has no effect. + +.. confval:: Start + + :Field: starttime + + If this is not empty, "now" (current time) must be after Start time for the + redirect to have effect. + +.. confval:: Stop + + :Field: endtime + + If this is not empty, "now" (current time) must be before Stop time for the + redirect to have effect. + +.. _usage-redirects-module-edit-form-notes-tab: + +Notes +~~~~~ + +.. confval:: Description + + :Field: description + + Add context to the corresponding redirect. The added information is also + displayed in the "Record information" info box above the edit form. + + +.. _regex-examples: + +Regex examples +-------------- + +.. _regex-examples-regex: + + +Example 1: Source path with regular expression and capturing group +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +redirect + ++-------------------------------------------------+-----------------------+----------------------------------------+ +| Source Path | Is Regular Expression | target | ++-------------------------------------------------+-----------------------+----------------------------------------+ +| `#^/path/([a-zA-Z]{1}[a-zA-Z0-9_/-]+)#` | true | :samp:`https://example.org/newpath/$1` | ++-------------------------------------------------+-----------------------+----------------------------------------+ + +with the following result: + ++--------------------------------------------+-----------------------------------------------+ +| URL | result URL | ++--------------------------------------------+-----------------------------------------------+ +| :samp:`https://example.org/path/something` | :samp:`https://example.org/newpath/something` | ++--------------------------------------------+-----------------------------------------------+ + +.. _regex-examples-regex-relative: + +Example 2: Source path with regular expression, capturing group and relative target +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +redirect + ++-------------------------------------------------+-----------------------+---------------+ +| Source Path | Is Regular Expression | target | ++-------------------------------------------------+-----------------------+---------------+ +| `#^/another/path/([a-zA-Z]{1}[a-zA-Z0-9_/-]+)#` | true | `/newpath/$1` | ++-------------------------------------------------+-----------------------+---------------+ + +with the following result: + ++----------------------------------------------------+--------------------------------------------------------+ +| URL | result URL | ++----------------------------------------------------+--------------------------------------------------------+ +| :samp:`https://example.org/another/path/something` | :samp:`https://example.org/relative/newpath/something` | ++----------------------------------------------------+--------------------------------------------------------+ + +Using a relative target is necessary if a redirect must work on multiple domains or multiple environments. + + +.. important:: + + TYPO3 will not syntax check the redirect. Make sure you enter working + redirects enclosed in delimiters. Use tools like https://regex101.com/, + if necessary. + +.. _automatic-redirect-creation: + +Automatic redirects creation +============================ + +Redirects are created automatically on slug changes, if EXT:redirects is +installed and automatic creation is enabled in +:ref:`site configuration `. + +A redirect from the old URL to the new URL will be created. All sub pages are +checked too and the slugs will be updated and redirects will be created for +these as well. + +After the creation of the redirects a notification will be shown to the user. + +.. figure:: ../Images/RedirectRevert.png + :class: with-shadow + + Revert redirect + +The notification contains two possible actions: + +- revert the complete slug update and remove the redirects +- or only remove the redirects + +.. note:: + + No redirects are generated for workspace versions in the TYPO3 backend. + The setting `redirect.autoCreateRedirects` is internally disabled in this + case. + + +.. |edit_action_image| image:: ../Images/EditAction.png + +.. |action_buttons_image| image:: ../Images/RedirectActionButtons.png diff --git a/Documentation/guides.xml b/Documentation/guides.xml new file mode 100644 index 0000000..9542de2 --- /dev/null +++ b/Documentation/guides.xml @@ -0,0 +1,21 @@ + + + + + diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..d159169 --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,339 @@ + GNU GENERAL PUBLIC LICENSE + Version 2, June 1991 + + Copyright (C) 1989, 1991 Free Software Foundation, Inc., + 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The licenses for most software are designed to take away your +freedom to share and change it. By contrast, the GNU General Public +License is intended to guarantee your freedom to share and change free +software--to make sure the software is free for all its users. This +General Public License applies to most of the Free Software +Foundation's software and to any other program whose authors commit to +using it. (Some other Free Software Foundation software is covered by +the GNU Lesser General Public License instead.) You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +this service if you wish), that you receive source code or can get it +if you want it, that you can change the software or use pieces of it +in new free programs; and that you know you can do these things. + + To protect your rights, we need to make restrictions that forbid +anyone to deny you these rights or to ask you to surrender the rights. +These restrictions translate to certain responsibilities for you if you +distribute copies of the software, or if you modify it. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must give the recipients all the rights that +you have. You must make sure that they, too, receive or can get the +source code. And you must show them these terms so they know their +rights. + + We protect your rights with two steps: (1) copyright the software, and +(2) offer you this license which gives you legal permission to copy, +distribute and/or modify the software. + + Also, for each author's protection and ours, we want to make certain +that everyone understands that there is no warranty for this free +software. If the software is modified by someone else and passed on, we +want its recipients to know that what they have is not the original, so +that any problems introduced by others will not reflect on the original +authors' reputations. + + Finally, any free program is threatened constantly by software +patents. We wish to avoid the danger that redistributors of a free +program will individually obtain patent licenses, in effect making the +program proprietary. To prevent this, we have made it clear that any +patent must be licensed for everyone's free use or not licensed at all. + + The precise terms and conditions for copying, distribution and +modification follow. + + GNU GENERAL PUBLIC LICENSE + TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + + 0. This License applies to any program or other work which contains +a notice placed by the copyright holder saying it may be distributed +under the terms of this General Public License. The "Program", below, +refers to any such program or work, and a "work based on the Program" +means either the Program or any derivative work under copyright law: +that is to say, a work containing the Program or a portion of it, +either verbatim or with modifications and/or translated into another +language. (Hereinafter, translation is included without limitation in +the term "modification".) Each licensee is addressed as "you". + +Activities other than copying, distribution and modification are not +covered by this License; they are outside its scope. The act of +running the Program is not restricted, and the output from the Program +is covered only if its contents constitute a work based on the +Program (independent of having been made by running the Program). +Whether that is true depends on what the Program does. + + 1. You may copy and distribute verbatim copies of the Program's +source code as you receive it, in any medium, provided that you +conspicuously and appropriately publish on each copy an appropriate +copyright notice and disclaimer of warranty; keep intact all the +notices that refer to this License and to the absence of any warranty; +and give any other recipients of the Program a copy of this License +along with the Program. + +You may charge a fee for the physical act of transferring a copy, and +you may at your option offer warranty protection in exchange for a fee. + + 2. You may modify your copy or copies of the Program or any portion +of it, thus forming a work based on the Program, and copy and +distribute such modifications or work under the terms of Section 1 +above, provided that you also meet all of these conditions: + + a) You must cause the modified files to carry prominent notices + stating that you changed the files and the date of any change. + + b) You must cause any work that you distribute or publish, that in + whole or in part contains or is derived from the Program or any + part thereof, to be licensed as a whole at no charge to all third + parties under the terms of this License. + + c) If the modified program normally reads commands interactively + when run, you must cause it, when started running for such + interactive use in the most ordinary way, to print or display an + announcement including an appropriate copyright notice and a + notice that there is no warranty (or else, saying that you provide + a warranty) and that users may redistribute the program under + these conditions, and telling the user how to view a copy of this + License. (Exception: if the Program itself is interactive but + does not normally print such an announcement, your work based on + the Program is not required to print an announcement.) + +These requirements apply to the modified work as a whole. If +identifiable sections of that work are not derived from the Program, +and can be reasonably considered independent and separate works in +themselves, then this License, and its terms, do not apply to those +sections when you distribute them as separate works. But when you +distribute the same sections as part of a whole which is a work based +on the Program, the distribution of the whole must be on the terms of +this License, whose permissions for other licensees extend to the +entire whole, and thus to each and every part regardless of who wrote it. + +Thus, it is not the intent of this section to claim rights or contest +your rights to work written entirely by you; rather, the intent is to +exercise the right to control the distribution of derivative or +collective works based on the Program. + +In addition, mere aggregation of another work not based on the Program +with the Program (or with a work based on the Program) on a volume of +a storage or distribution medium does not bring the other work under +the scope of this License. + + 3. You may copy and distribute the Program (or a work based on it, +under Section 2) in object code or executable form under the terms of +Sections 1 and 2 above provided that you also do one of the following: + + a) Accompany it with the complete corresponding machine-readable + source code, which must be distributed under the terms of Sections + 1 and 2 above on a medium customarily used for software interchange; or, + + b) Accompany it with a written offer, valid for at least three + years, to give any third party, for a charge no more than your + cost of physically performing source distribution, a complete + machine-readable copy of the corresponding source code, to be + distributed under the terms of Sections 1 and 2 above on a medium + customarily used for software interchange; or, + + c) Accompany it with the information you received as to the offer + to distribute corresponding source code. (This alternative is + allowed only for noncommercial distribution and only if you + received the program in object code or executable form with such + an offer, in accord with Subsection b above.) + +The source code for a work means the preferred form of the work for +making modifications to it. For an executable work, complete source +code means all the source code for all modules it contains, plus any +associated interface definition files, plus the scripts used to +control compilation and installation of the executable. However, as a +special exception, the source code distributed need not include +anything that is normally distributed (in either source or binary +form) with the major components (compiler, kernel, and so on) of the +operating system on which the executable runs, unless that component +itself accompanies the executable. + +If distribution of executable or object code is made by offering +access to copy from a designated place, then offering equivalent +access to copy the source code from the same place counts as +distribution of the source code, even though third parties are not +compelled to copy the source along with the object code. + + 4. You may not copy, modify, sublicense, or distribute the Program +except as expressly provided under this License. Any attempt +otherwise to copy, modify, sublicense or distribute the Program is +void, and will automatically terminate your rights under this License. +However, parties who have received copies, or rights, from you under +this License will not have their licenses terminated so long as such +parties remain in full compliance. + + 5. You are not required to accept this License, since you have not +signed it. However, nothing else grants you permission to modify or +distribute the Program or its derivative works. These actions are +prohibited by law if you do not accept this License. Therefore, by +modifying or distributing the Program (or any work based on the +Program), you indicate your acceptance of this License to do so, and +all its terms and conditions for copying, distributing or modifying +the Program or works based on it. + + 6. Each time you redistribute the Program (or any work based on the +Program), the recipient automatically receives a license from the +original licensor to copy, distribute or modify the Program subject to +these terms and conditions. You may not impose any further +restrictions on the recipients' exercise of the rights granted herein. +You are not responsible for enforcing compliance by third parties to +this License. + + 7. If, as a consequence of a court judgment or allegation of patent +infringement or for any other reason (not limited to patent issues), +conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot +distribute so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you +may not distribute the Program at all. For example, if a patent +license would not permit royalty-free redistribution of the Program by +all those who receive copies directly or indirectly through you, then +the only way you could satisfy both it and this License would be to +refrain entirely from distribution of the Program. + +If any portion of this section is held invalid or unenforceable under +any particular circumstance, the balance of the section is intended to +apply and the section as a whole is intended to apply in other +circumstances. + +It is not the purpose of this section to induce you to infringe any +patents or other property right claims or to contest validity of any +such claims; this section has the sole purpose of protecting the +integrity of the free software distribution system, which is +implemented by public license practices. Many people have made +generous contributions to the wide range of software distributed +through that system in reliance on consistent application of that +system; it is up to the author/donor to decide if he or she is willing +to distribute software through any other system and a licensee cannot +impose that choice. + +This section is intended to make thoroughly clear what is believed to +be a consequence of the rest of this License. + + 8. If the distribution and/or use of the Program is restricted in +certain countries either by patents or by copyrighted interfaces, the +original copyright holder who places the Program under this License +may add an explicit geographical distribution limitation excluding +those countries, so that distribution is permitted only in or among +countries not thus excluded. In such case, this License incorporates +the limitation as if written in the body of this License. + + 9. The Free Software Foundation may publish revised and/or new versions +of the General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + +Each version is given a distinguishing version number. If the Program +specifies a version number of this License which applies to it and "any +later version", you have the option of following the terms and conditions +either of that version or of any later version published by the Free +Software Foundation. If the Program does not specify a version number of +this License, you may choose any version ever published by the Free Software +Foundation. + + 10. If you wish to incorporate parts of the Program into other free +programs whose distribution conditions are different, write to the author +to ask for permission. For software which is copyrighted by the Free +Software Foundation, write to the Free Software Foundation; we sometimes +make exceptions for this. Our decision will be guided by the two goals +of preserving the free status of all derivatives of our free software and +of promoting the sharing and reuse of software generally. + + NO WARRANTY + + 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY +FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN +OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES +PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED +OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS +TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE +PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, +REPAIR OR CORRECTION. + + 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR +REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, +INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING +OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED +TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY +YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER +PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE +POSSIBILITY OF SUCH DAMAGES. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +convey the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along + with this program; if not, write to the Free Software Foundation, Inc., + 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +Also add information on how to contact you by electronic and paper mail. + +If the program is interactive, make it output a short notice like this +when it starts in an interactive mode: + + Gnomovision version 69, Copyright (C) year name of author + Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, the commands you use may +be called something other than `show w' and `show c'; they could even be +mouse-clicks or menu items--whatever suits your program. + +You should also get your employer (if you work as a programmer) or your +school, if any, to sign a "copyright disclaimer" for the program, if +necessary. Here is a sample; alter the names: + + Yoyodyne, Inc., hereby disclaims all copyright interest in the program + `Gnomovision' (which makes passes at compilers) written by James Hacker. + + , 1 April 1989 + Ty Coon, President of Vice + +This General Public License does not permit incorporating your program into +proprietary programs. If your program is a subroutine library, you may +consider it more useful to permit linking proprietary applications with the +library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. diff --git a/README.rst b/README.rst new file mode 100644 index 0000000..5b236f5 --- /dev/null +++ b/README.rst @@ -0,0 +1,11 @@ +============================= +TYPO3 extension ``redirects`` +============================= + +This extension makes it possible to create manual redirects, list existing +redirects and automatically create redirects on slug changes. + +:Repository: https://github.com/typo3/typo3 +:Issues: https://forge.typo3.org/ +:Read online: https://docs.typo3.org/c/typo3/cms-redirects/main/en-us/ +:Packagist: https://packagist.org/packages/typo3/cms-redirects diff --git a/Resources/Private/Language/Modules/qrcodes.xlf b/Resources/Private/Language/Modules/qrcodes.xlf new file mode 100644 index 0000000..ea956d5 --- /dev/null +++ b/Resources/Private/Language/Modules/qrcodes.xlf @@ -0,0 +1,47 @@ + + + +
+ + + QR Codes + + + QR Codes Administration + + + Generate and customize QR codes to provide quick, scannable access to your digital content. + + + QR Code Management + + + Add QR Code + + + No QR Code could be found with the current set of filters applied. + + + No QR Codes found + + + There are currently no QR Codes found in the database. + + + Create new QR Code + + + Delete QR Codes + + + Delete all marked QR Codes permanently? You cannot undo this action. + + + Download + + + Show QR Code + + + + diff --git a/Resources/Private/Language/Modules/redirects.xlf b/Resources/Private/Language/Modules/redirects.xlf new file mode 100644 index 0000000..2e37922 --- /dev/null +++ b/Resources/Private/Language/Modules/redirects.xlf @@ -0,0 +1,17 @@ + + + +
+ + + Redirect Administration + + + Manage URL redirects to prevent broken links and route traffic to preferred destinations. + + + Redirects + + + + diff --git a/Resources/Private/Language/Modules/short_urls.xlf b/Resources/Private/Language/Modules/short_urls.xlf new file mode 100644 index 0000000..866cc24 --- /dev/null +++ b/Resources/Private/Language/Modules/short_urls.xlf @@ -0,0 +1,50 @@ + + + +
+ + + Short URLs + + + Short URLs Administration + + + Create and manage Short URLs that redirect to longer links for easier sharing and tracking. + + + Short URL Management + + + Add Short URL + + + No Short URL could be found with the current set of filters applied. + + + No Short URLs found + + + There are currently no Short URLs found in the database. + + + Create new Short URL + + + Delete Short URLs + + + Delete all marked Short URLs permanently? You cannot undo this action. + + + Show Short URL + + + Generate Short URL + + + This Short URL already exists. Please choose a different source host or source path. + + + + diff --git a/Resources/Private/Language/locallang.xlf b/Resources/Private/Language/locallang.xlf new file mode 100644 index 0000000..b2f183c --- /dev/null +++ b/Resources/Private/Language/locallang.xlf @@ -0,0 +1,56 @@ + + + +
+ + + Redirect Settings + + + Update the slugs of all sub pages automatically + + + Create redirects for pages with a new slug automatically + + + Time To Live of redirect records in days + + + HTTP Status Code for the redirect, 301 is the default + + + Cleanup redirects older than provided number of days + + + Cleanup redirects matching provided domain(s) + + + Cleanup redirects matching hit counts lower than given number + + + Cleanup redirects matching given path (as database like expression) + + + Cleanup redirects matching provided creation type + + + Cleanup redirects matching provided status code(s) + + + Cleanup redirects matching provided integrity status + + + Cleanup redirects matching provided redirect type + + + on + + + [Not Tracked] + + + Not enough information for generating a QR Code. + + + + diff --git a/Resources/Private/Language/locallang_db.xlf b/Resources/Private/Language/locallang_db.xlf new file mode 100644 index 0000000..7aa760c --- /dev/null +++ b/Resources/Private/Language/locallang_db.xlf @@ -0,0 +1,149 @@ + + + +
+ + + Redirect + + + QR Code + + + Short URL + + + Source Domain + + + Source Path + + + SSL Redirect + + + Force SSL Redirect + + + GET Parameters + + + Keep GET Parameters + + + GET Parameters (source) + + + Respect GET Parameters + + + Target + + + Status Code HTTP Header + + + 301 Moved Permanently + + + 302 Found + + + 303 See Other + + + 307 Temporary Redirect + + + 308 Permanent Redirect + + + Keep HTTP method + + + Change HTTP method to GET + + + Hit count + + + Last Hit on + + + Hit Counter + + + Disable Hit Counter + + + Disable + + + Statistics + + + Protected + + + When enabled, this redirect will be skipped by scheduled cleanup tasks that might delete redirects under certain conditions. + + + Is regular expression? + + + Yes + + + Disable + + + Deactivate redirect + + + Creation Type + + + Defines how the redirect was created. + + + manually created + + + automatically created + + + ... when a redirect has been hit + + + Integrity status + + + Redirects can be checked via the command redirects:checkintegrity. + + + No conflict + + + Self reference + + + Invalid target + + + UID + + + Created by + + + Short URL + + + Enter a Short URL or generate a random one automatically. Once saved, the Short URL cannot be changed. + + + Generate Short URL + + + + diff --git a/Resources/Private/Language/locallang_extconf.xlf b/Resources/Private/Language/locallang_extconf.xlf new file mode 100644 index 0000000..af2482f --- /dev/null +++ b/Resources/Private/Language/locallang_extconf.xlf @@ -0,0 +1,14 @@ + + + +
+ + + Show report warning if checkintegrity has not been run. + + + Set validity period (seconds) for the last checkintegrity report. + + + + diff --git a/Resources/Private/Language/locallang_module_redirect.xlf b/Resources/Private/Language/locallang_module_redirect.xlf new file mode 100644 index 0000000..647b382 --- /dev/null +++ b/Resources/Private/Language/locallang_module_redirect.xlf @@ -0,0 +1,131 @@ + + + +
+ + + Global redirect (any domain) + + + Redirect Management + + + Add redirect + + + No redirects found + + + There are currently no redirect records found in the database. + + + Create new redirect + + + Access denied + + + You are not allowed to list or modify redirects due to insufficient permissions. + + + No redirects found + + + With the current set of filters applied, no redirect could be found. + + + Remove all filter + + + Show All + + + Status Code + + + Creation type + + + Show All + + + Protected + + + Show All + + + unprotected + + + protected + + + Integrity status + + + Show All + + + Never hit + + + Filter + + + Reset + + + Disabled + + + /my-path/ or #^/my-path/$# (when regex enabled) + + + e.g. /AbCdEfGh + + + Copy Short URL to clipboard + + + Page ID + + + Status Code + + + Reset hit counter + + + Reset the hit counter of this record? + + + Are you sure you want to reset the hit counter of this record? + + + Never + + + Go to + + + View redirect + + + Protected + + + No + + + Yes + + + Delete redirects + + + Are you sure you want to delete all marked redirects? + + + + diff --git a/Resources/Private/Language/locallang_reports.xlf b/Resources/Private/Language/locallang_reports.xlf new file mode 100644 index 0000000..48df6f3 --- /dev/null +++ b/Resources/Private/Language/locallang_reports.xlf @@ -0,0 +1,32 @@ + + + +
+ + + Redirects + + + Conflicting Redirects + + + None + + + %1$s conflicting redirects + + + These redirects cause a conflict as there are pages that are still accessible with the same URL. + + + Redirects integrity check + + + List of conflicting redirects may not be up to date + + + Regularly run the console command redirects:checkintegrity. + + + + diff --git a/Resources/Private/Language/locallang_slug_service.xlf b/Resources/Private/Language/locallang_slug_service.xlf new file mode 100644 index 0000000..d89d8c9 --- /dev/null +++ b/Resources/Private/Language/locallang_slug_service.xlf @@ -0,0 +1,44 @@ + + + +
+ + + Slugs updated and redirects created + + + Because you renamed a slug, the slugs of all sub-pages were updated and redirects were created for you automatically. + + + Slugs updated + + + Because you renamed a slug, the slugs of all sub-pages were updated for you automatically. + + + Revert update + + + Revert redirects only + + + An error occurred + + + Sorry something went wrong. + + + Revert successful + + + All slug changes of sub pages have been reverted. + + + Revert successful + + + All created redirects have been reverted. + + + + diff --git a/Resources/Private/Partials/Pagination.fluid.html b/Resources/Private/Partials/Pagination.fluid.html new file mode 100644 index 0000000..776c6ca --- /dev/null +++ b/Resources/Private/Partials/Pagination.fluid.html @@ -0,0 +1,88 @@ + + + diff --git a/Resources/Private/Templates/Management/Overview.fluid.html b/Resources/Private/Templates/Management/Overview.fluid.html new file mode 100644 index 0000000..4e62b91 --- /dev/null +++ b/Resources/Private/Templates/Management/Overview.fluid.html @@ -0,0 +1,499 @@ + + + + + + + + + + + + + + + + + + + + + + +

+ + + + + + + + + + +

+ + + + + + +
+ + +
+ +
+
+ + +

+ + + + + +
+
+
+
+
+ +
+ + + Partial is used from EXT:backend/Resources/Private/Partials/MultiRecordSelection/redirectss.html + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + +
+ + + + + + + + + + + + + + {redirect.source_host} + + + + {redirect.source_path} + + + + {redirect.source_path} + + + + + + {f:if(condition: targetUri, then:targetUri, else:redirect.target)} + + + (: + {pageRow.uid}) + {redirect.target_statuscode} + + + + + + + + + + + {redirect.hitcount} + + + + @{redirect.lasthiton} + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + + + + + + + + + + + + + + + + +
+
+
+ + +
+ + + + + + +
+ + + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+
+ + +
+
+
+
+ + +
+
+
+
+ diff --git a/Resources/Private/Templates/QrCode/Overview.fluid.html b/Resources/Private/Templates/QrCode/Overview.fluid.html new file mode 100644 index 0000000..771e71e --- /dev/null +++ b/Resources/Private/Templates/QrCode/Overview.fluid.html @@ -0,0 +1,403 @@ + + + + + + + + + + + + + + +

+ + + + + + + + + + +

+ + + + + + +
+ + +
+ +
+
+ + +

+ +

+ + + +
+
+
+
+
+ +
+ + + Partial is used from EXT:backend/Resources/Private/Partials/MultiRecordSelection/Actions.html + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + +
+ + + + + + + + + + + + + + + + {redirect.source_host} + + + + + {f:if(condition: targetUri, then:targetUri, else:redirect.target)} + + +
(: + {pageRow.uid})
+
+ {redirect.description} + + + {redirect.hitcount} + + + + @{redirect.lasthiton} + + + +
+ + + + + + + + + + + + + + + + + + + + + +
+ +
+
+ + +
+ + + + + + +
+ + + +
+
+ + +
+
+ + +
+ +
+
+ + +
+
+
+
+ + +
+
+
+
+ diff --git a/Resources/Private/Templates/Report/RedirectStatus.fluid.html b/Resources/Private/Templates/Report/RedirectStatus.fluid.html new file mode 100644 index 0000000..910c8eb --- /dev/null +++ b/Resources/Private/Templates/Report/RedirectStatus.fluid.html @@ -0,0 +1,19 @@ + + + +

+
    + +
  • + {conflict.uri -> f:format.crop(maxCharacters: '100')}
    + : {conflict.redirect.source_host}
    + : {conflict.redirect.source_path -> f:format.crop(maxCharacters: '100')} +
  • +
    +
+
+ + diff --git a/Resources/Private/Templates/ShortUrl/Overview.fluid.html b/Resources/Private/Templates/ShortUrl/Overview.fluid.html new file mode 100644 index 0000000..ea2cc35 --- /dev/null +++ b/Resources/Private/Templates/ShortUrl/Overview.fluid.html @@ -0,0 +1,451 @@ + + + + + + + + + + + + + + +

+ + + + + + + + + + +

+ + + + + + +
+ + +
+ +
+
+ + +

+ +

+ + + +
+
+
+
+
+ +
+ + + Partial is used from EXT:backend/Resources/Private/Partials/MultiRecordSelection/Actions.html + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + {protocol}://{redirect.source_host}{redirect.source_path} + + + + + {f:if(condition: targetUri, then:targetUri, else:redirect.target)} + + +
(: + {pageRow.uid})
+
+ {redirect.description} + + + {redirect.hitcount} + + + + @{redirect.lasthiton} + + + +
+ + + + + + + + + + + + + + + + + + + + + +
+ +
+
+ + +
+ + + + + + +
+ + + +
+
+ + +
+
+ + +
+
+ + +
+ +
+
+ + +
+
+
+
+ + + + +
+
+
+
+ diff --git a/Resources/Public/Icons/Extension.svg b/Resources/Public/Icons/Extension.svg new file mode 100644 index 0000000..ff4a0c5 --- /dev/null +++ b/Resources/Public/Icons/Extension.svg @@ -0,0 +1,12 @@ + + + + diff --git a/Resources/Public/Icons/mimetypes-x-sys_redirect.svg b/Resources/Public/Icons/mimetypes-x-sys_redirect.svg new file mode 100644 index 0000000..94fdae4 --- /dev/null +++ b/Resources/Public/Icons/mimetypes-x-sys_redirect.svg @@ -0,0 +1 @@ + diff --git a/Resources/Public/JavaScript/event-handler.js b/Resources/Public/JavaScript/event-handler.js new file mode 100644 index 0000000..826d097 --- /dev/null +++ b/Resources/Public/JavaScript/event-handler.js @@ -0,0 +1,13 @@ +/* + * 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! + */ +import c from"@typo3/core/ajax/ajax-request.js";import n from"@typo3/backend/notification.js";import a from"@typo3/backend/action-button/deferred-action.js";import i from"~labels/redirects.slug_service";class l{constructor(){document.addEventListener("typo3:redirects:slugChanged",e=>this.onSlugChanged(e.detail))}dispatchCustomEvent(e,r=null){const o=new CustomEvent(e,{detail:r});document.dispatchEvent(o)}onSlugChanged(e){const r=[],o=e.correlations;e.autoUpdateSlugs&&r.push({label:i.get("notification.redirects.button.revert_update"),action:new a(async()=>{await this.revert([o.correlationIdPageUpdate,o.correlationIdSlugUpdate,o.correlationIdRedirectCreation])})}),e.autoCreateRedirects&&r.push({label:i.get("notification.redirects.button.revert_redirect"),action:new a(async()=>{await this.revert([o.correlationIdRedirectCreation])})});let t=i.get("notification.slug_only.title"),s=i.get("notification.slug_only.message");e.autoCreateRedirects&&(t=i.get("notification.slug_and_redirects.title"),s=i.get("notification.slug_and_redirects.message")),n.info(t,s,0,r)}revert(e){const r=new c(TYPO3.settings.ajaxUrls.redirects_revert_correlation).post({correlation_ids:e});return r.then(async o=>{const t=await o.resolve();t.status==="ok"&&(n.success(t.title,t.message),window.location.reload(),top.document.dispatchEvent(new CustomEvent("typo3:pagetree:refresh"))),t.status==="error"&&n.error(t.title,t.message)}).catch(()=>{n.error(i.get("redirects_error_title"),i.get("redirects_error_message"))}),r}}var d=new l;export{d as default}; diff --git a/Resources/Public/JavaScript/form-engine-evaluation.js b/Resources/Public/JavaScript/form-engine-evaluation.js new file mode 100644 index 0000000..01c22a9 --- /dev/null +++ b/Resources/Public/JavaScript/form-engine-evaluation.js @@ -0,0 +1,13 @@ +/* + * 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! + */ +import r from"@typo3/backend/form-engine-validation.js";class o{static registerCustomEvaluation(t){r.registerCustomEvaluation(t,o.evaluateSourceHost)}static evaluateSourceHost(t){return t==="*"?t:(t.includes("://")||(t="http://"+t),new URL(t).host)}}export{o as FormEngineEvaluation}; diff --git a/Resources/Public/JavaScript/redirects-module.js b/Resources/Public/JavaScript/redirects-module.js new file mode 100644 index 0000000..5e3a822 --- /dev/null +++ b/Resources/Public/JavaScript/redirects-module.js @@ -0,0 +1,13 @@ +/* + * 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! + */ +import o from"@typo3/core/event/regular-event.js";class i{constructor(){const e=document.querySelector('form[data-on-submit="processNavigate"]');e!==null&&(new o("change",this.executeSubmit.bind(this)).delegateTo(document,'[data-on-change="submit"]'),new o("submit",this.processNavigate.bind(this)).bindTo(e))}executeSubmit(e){const t=e.target;(t instanceof HTMLSelectElement||t instanceof HTMLInputElement&&t.type==="checkbox")&&t.form.submit()}processNavigate(e){const t=e.target;if(!(t instanceof HTMLFormElement))return;e.preventDefault();const n=t.elements.namedItem("paginator-target-page"),s=parseInt(n.dataset.numberOfPages,10);let r=n.dataset.url,a=parseInt(n.value,10);a>s?a=s:a<1&&(a=1),r=r.replace("987654322",a.toString()),self.location.href=r}}var c=new i;export{c as default}; diff --git a/Resources/Public/JavaScript/short-url-generator.js b/Resources/Public/JavaScript/short-url-generator.js new file mode 100644 index 0000000..c6c4abd --- /dev/null +++ b/Resources/Public/JavaScript/short-url-generator.js @@ -0,0 +1,13 @@ +/* + * 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! + */ +import n from"@typo3/core/document-service.js";import d from"@typo3/backend/form-engine.js";import t from"@typo3/backend/form-engine-validation.js";import o from"@typo3/core/ajax/ajax-request.js";import l from"@typo3/backend/notification.js";class h{constructor(e){this.controlElement=null,this.humanReadableField=null,this.sourceHostField=null,this.debounceTimer=null,this.hasDuplicateError=!1,n.ready().then(()=>{this.controlElement=document.getElementById(e),this.humanReadableField=document.querySelector('input[data-formengine-input-name="'+this.controlElement.dataset.itemName.replace("[short_url]","[source_path]")+'"]'),this.sourceHostField=document.querySelector('input[data-formengine-input-name="'+this.controlElement.dataset.itemName.replace("[short_url]","[source_host]")+'"]'),this.controlElement.addEventListener("click",this.generateShortUrl.bind(this)),this.humanReadableField.addEventListener("input",this.debouncedValidate.bind(this)),this.sourceHostField&&this.sourceHostField.addEventListener("input",this.debouncedValidate.bind(this))})}debouncedValidate(){this.debounceTimer&&clearTimeout(this.debounceTimer),this.debounceTimer=setTimeout(()=>{this.validateShortUrl()},400)}validateShortUrl(){const e=this.humanReadableField.value,a=this.sourceHostField?this.sourceHostField.value:"";if(e===""){this.setFieldError(!1);return}new o(TYPO3.settings.ajaxUrls.short_url_validate).post({source_host:a,source_path:e}).then(async i=>{const s=await i.resolve(),r=!s.isUnique;this.setFieldError(r),r&&!this.hasDuplicateError&&s.message&&l.warning(s.message),this.hasDuplicateError=r}).catch(()=>{})}setFieldError(e){if(e){const a=[this.humanReadableField,this.sourceHostField].filter(Boolean);for(const i of a)i.classList.add(t.errorClass),i.setAttribute("aria-invalid","true");this.humanReadableField.closest(t.markerSelector)?.querySelector(t.labelSelector)?.classList.add(t.errorClass),t.markParentTab(this.humanReadableField,!1),this.humanReadableField.closest("form")?.dispatchEvent(new CustomEvent("t3-formengine-postfieldvalidation",{detail:{field:this.humanReadableField,isValid:!1},cancelable:!1,bubbles:!0}))}else this.sourceHostField&&(this.sourceHostField.classList.remove(t.errorClass),this.sourceHostField.removeAttribute("aria-invalid")),t.validateField(this.humanReadableField)}generateShortUrl(e){e.preventDefault();const a=this.sourceHostField?this.sourceHostField.value:"";new o(TYPO3.settings.ajaxUrls.short_url_generate).post({source_host:a}).then(async i=>{const s=await i.resolve();s.success===!0?(this.humanReadableField.value=s.shortUrl,this.humanReadableField.dispatchEvent(new Event("change")),t.validateField(this.humanReadableField),d.markFieldAsChanged(this.humanReadableField),this.setFieldError(!1)):l.warning(s.message||"No Short URL was generated")}).catch(()=>{l.error("Short URL could not be generated")})}}export{h as default}; diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..ee3269b --- /dev/null +++ b/composer.json @@ -0,0 +1,66 @@ +{ + "name": "typo3/cms-redirects", + "type": "typo3-cms-framework", + "description": "TYPO3 CMS Redirects - Create manual redirects, list existing redirects and automatically create\nredirects on slug changes.", + "homepage": "https://typo3.community/", + "funding": [ + { + "type": "membership", + "url": "https://typo3.org/membership" + } + ], + "license": [ + "GPL-2.0-or-later" + ], + "authors": [ + { + "name": "TYPO3 Core Team", + "email": "typo3cms@typo3.org", + "role": "Developer" + } + ], + "support": { + "issues": "https://forge.typo3.org/issues/", + "forum": "https://talk.typo3.org/", + "source": "https://github.com/TYPO3/typo3/", + "docs": "https://docs.typo3.org/c/typo3/cms-redirects/main/en-us/", + "rss": "https://news.typo3.com/rss/", + "chat": "https://typo3.community/meet/slack/", + "security": "https://typo3.org/security/" + }, + "config": { + "sort-packages": true + }, + "require": { + "doctrine/dbal": "~4.4.3", + "psr/http-message": "^1.1 || ^2.0", + "psr/log": "^3.0.1", + "symfony/console": "^7.4.8", + "typo3/cms-backend": "15.0.*@dev", + "typo3/cms-core": "15.0.*@dev", + "typo3fluid/fluid": "^5.3.1" + }, + "conflict": { + "typo3/cms": "*" + }, + "suggest": { + "typo3/cms-reports": "Get reports of redirects", + "typo3/cms-scheduler": "Execute commands to update redirect status" + }, + "extra": { + "branch-alias": { + "dev-main": "15.0.x-dev" + }, + "typo3/cms": { + "extension-key": "redirects", + "Package": { + "partOfFactoryDefault": true + } + } + }, + "autoload": { + "psr-4": { + "TYPO3\\CMS\\Redirects\\": "Classes/" + } + } +} diff --git a/ext_conf_template.txt b/ext_conf_template.txt new file mode 100644 index 0000000..d44bfde --- /dev/null +++ b/ext_conf_template.txt @@ -0,0 +1,7 @@ +# customsubcategory=checkintegrity=Checkintegrity + +# cat=reports/checkintegrity; type=boolean; label=LLL:EXT:redirects/Resources/Private/Language/locallang_extconf.xlf:showCheckintegrityInfoInReports.enable +showCheckIntegrityInfoInReports = 1 + +# cat=reports/checkintegrity; type=int; label=LLL:EXT:redirects/Resources/Private/Language/locallang_extconf.xlf:showCheckintegrityInfoInReports.seconds +showCheckIntegrityInfoInReportsSeconds = 86400 diff --git a/ext_localconf.php b/ext_localconf.php new file mode 100644 index 0000000..c9b1746 --- /dev/null +++ b/ext_localconf.php @@ -0,0 +1,86 @@ +rebuildRedirectCacheIfNecessary'; +$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_tcemain.php']['processDatamapClass']['redirects'] = DataHandlerSlugUpdateHook::class; +$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_tcemain.php']['processDatamapClass']['redirects-qrcode'] = HandleNewQrCodeRecord::class; +$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_tcemain.php']['processDatamapClass']['redirects-short-url'] = HandleNewShortUrlRecord::class; +$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_tcemain.php']['processDatamapClass']['redirectsAccessGuard'] = DataHandlerPermissionGuardHook::class; + +// Inject sys_domains into valuepicker form +$GLOBALS['TYPO3_CONF_VARS']['SYS']['formEngine']['formDataGroup']['tcaDatabaseRecord'] +[ValuePickerItemDataProvider::class] = [ + 'depends' => [ + TcaInputPlaceholders::class, + ], +]; + +// Renders Redirect creation information. i.e. backend username and creation date. +$GLOBALS['TYPO3_CONF_VARS']['SYS']['formEngine']['nodeRegistry'][1761573166] = [ + 'nodeName' => 'creationInformation', + 'priority' => 40, + 'class' => RenderCreationInformation::class, +]; + +// Renders QR code with options +$GLOBALS['TYPO3_CONF_VARS']['SYS']['formEngine']['nodeRegistry'][1764867024] = [ + 'nodeName' => 'qrCode', + 'priority' => 40, + 'class' => QrCodeElement::class, +]; + +// Renders Short URL element +$GLOBALS['TYPO3_CONF_VARS']['SYS']['formEngine']['nodeRegistry'][1770156231] = [ + 'nodeName' => 'shortUrl', + 'priority' => 40, + 'class' => ShortUrlElement::class, +]; + +// Renders shortUrlGenerator field control +$GLOBALS['TYPO3_CONF_VARS']['SYS']['formEngine']['nodeRegistry'][1770204638] = [ + 'nodeName' => 'shortUrlGenerator', + 'priority' => 40, + 'class' => ShortUrlGenerator::class, +]; + +// Set "source_host" to "readOnly" for the sys_redirects of type "qrcode" +$GLOBALS['TYPO3_CONF_VARS']['SYS']['formEngine']['formDataGroup']['tcaDatabaseRecord'] +[QrCodeSourceHostDataProvider::class] = [ + 'depends' => [ + TcaInputPlaceholders::class, + ], +]; + +// Set "short_url" to "readOnly" for the sys_redirects of type "short_url" +$GLOBALS['TYPO3_CONF_VARS']['SYS']['formEngine']['formDataGroup']['tcaDatabaseRecord'] +[ShortUrlDataProvider::class] = [ + 'depends' => [ + TcaInputPlaceholders::class, + ], +]; + +// Add validation call for form field source_host and source_path +$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['tce']['formevals'][SourceHost::class] = ''; + +// Register update signal to send delayed notifications +$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_befunc.php']['updateSignalHook']['redirects:slugChanged'] = DispatchNotificationHook::class . '->dispatchNotification'; diff --git a/ext_tables.sql b/ext_tables.sql new file mode 100644 index 0000000..84b0de8 --- /dev/null +++ b/ext_tables.sql @@ -0,0 +1,7 @@ +CREATE TABLE sys_redirect ( + # @todo: Declared type=input but should be something different + hitcount int(11) DEFAULT '0' NOT NULL, + redirect_type varchar(100) DEFAULT 'default', + createdby int(11) UNSIGNED DEFAULT '0' NOT NULL, + KEY index_source (source_host(80),source_path(80)) +);