commit 1a6eae9988f161393de081feddcb866edea87701 Author: Sven Wappler Date: Mon Aug 10 22:31:33 2026 +0200 TYPO3 v15 dev-main snapshot () 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 0000000..aef6df6 Binary files /dev/null and b/Documentation/Images/EditAction.png differ diff --git a/Documentation/Images/InstallActivate.png b/Documentation/Images/InstallActivate.png new file mode 100644 index 0000000..9dfd3d0 Binary files /dev/null and b/Documentation/Images/InstallActivate.png differ diff --git a/Documentation/Images/RedirectActionButtons.png b/Documentation/Images/RedirectActionButtons.png new file mode 100644 index 0000000..abc0a16 Binary files /dev/null and b/Documentation/Images/RedirectActionButtons.png differ diff --git a/Documentation/Images/RedirectAllowedExcludefields.png b/Documentation/Images/RedirectAllowedExcludefields.png new file mode 100644 index 0000000..2753e73 Binary files /dev/null and b/Documentation/Images/RedirectAllowedExcludefields.png differ diff --git a/Documentation/Images/RedirectDetective.png b/Documentation/Images/RedirectDetective.png new file mode 100644 index 0000000..f7ee03c Binary files /dev/null and b/Documentation/Images/RedirectDetective.png differ diff --git a/Documentation/Images/RedirectEdit.png b/Documentation/Images/RedirectEdit.png new file mode 100644 index 0000000..82097ae Binary files /dev/null and b/Documentation/Images/RedirectEdit.png differ diff --git a/Documentation/Images/RedirectList.png b/Documentation/Images/RedirectList.png new file mode 100644 index 0000000..c798b85 Binary files /dev/null and b/Documentation/Images/RedirectList.png differ diff --git a/Documentation/Images/RedirectRevert.png b/Documentation/Images/RedirectRevert.png new file mode 100644 index 0000000..fab58ec Binary files /dev/null and b/Documentation/Images/RedirectRevert.png differ diff --git a/Documentation/Images/RedirectsEditStatistics.png b/Documentation/Images/RedirectsEditStatistics.png new file mode 100644 index 0000000..27f960b Binary files /dev/null and b/Documentation/Images/RedirectsEditStatistics.png differ diff --git a/Documentation/Images/RedirectsMenu.png b/Documentation/Images/RedirectsMenu.png new file mode 100644 index 0000000..9d94106 Binary files /dev/null and b/Documentation/Images/RedirectsMenu.png differ diff --git a/Documentation/Images/SystemReportConflicts.png b/Documentation/Images/SystemReportConflicts.png new file mode 100644 index 0000000..cd2a4d6 Binary files /dev/null and b/Documentation/Images/SystemReportConflicts.png differ diff --git a/Documentation/Includes.rst.txt b/Documentation/Includes.rst.txt new file mode 100644 index 0000000..2362507 --- /dev/null +++ b/Documentation/Includes.rst.txt @@ -0,0 +1 @@ +.. You can put central messages to display on all pages here diff --git a/Documentation/Index.rst b/Documentation/Index.rst new file mode 100644 index 0000000..0d08190 --- /dev/null +++ b/Documentation/Index.rst @@ -0,0 +1,59 @@ +.. include:: /Includes.rst.txt + +.. _start: + +=============== +TYPO3 Redirects +=============== + +:Extension key: + redirects + +:Package name: + typo3/cms-redirects + +:Version: + |release| + +:Language: + en + +:Author: + TYPO3 contributors + +:License: + This document is published under the + `Creative Commons BY-NC-SA 4.0 `__ + 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)) +);