From 63b4604eddb7e25f4655dbfc1622dc1beecad70d Mon Sep 17 00:00:00 2001 From: Benni Mack Date: Tue, 8 Sep 2026 22:14:27 +0200 Subject: [PATCH] [FEATURE] Add TYPO3 v14 support Two of the core APIs used here were already removed or made inaccessible in v13, so the declared v13 support never actually worked: * PageRepository::DOKTYPE_RECYCLER was removed in v13.0. The value 255 is kept as a literal, so v12 installations keep skipping those pages. * RootlineUtility::getCacheIdentifier() is protected since v13.1 and is therefore no longer part of the debug log message. Page and language uids are now cast to int before they are passed to the strictly typed core APIs, since the database returns them as strings on some platforms. A failing page no longer aborts the whole run, it is reported and skipped instead. The auth service registration in ext_localconf.php is dropped: its "getGroupsFE" subtype was removed in v11, and the class is already wired as a listener for ModifyResolvedFrontendGroupsEvent. Supported versions are now v12 LTS, v13 LTS and v14 LTS. --- .../FrontendUserGroupInjector.php | 14 +++-- Classes/Command/WarmupCommand.php | 49 ++++++++-------- Classes/FrontendRequestBuilder.php | 18 ++++-- Classes/Service/PageWarmupService.php | 57 ++++++++++--------- Classes/Service/RootlineWarmupService.php | 32 ++++++----- Classes/Service/WarmupServiceInterface.php | 20 +++++++ README.md | 42 ++++++++------ composer.json | 4 +- ext_emconf.php | 4 +- ext_localconf.php | 18 ------ 10 files changed, 147 insertions(+), 111 deletions(-) create mode 100644 Classes/Service/WarmupServiceInterface.php delete mode 100644 ext_localconf.php diff --git a/Classes/Authentication/FrontendUserGroupInjector.php b/Classes/Authentication/FrontendUserGroupInjector.php index 7c518b0..83f6205 100644 --- a/Classes/Authentication/FrontendUserGroupInjector.php +++ b/Classes/Authentication/FrontendUserGroupInjector.php @@ -18,7 +18,8 @@ use TYPO3\CMS\Frontend\Authentication\ModifyResolvedFrontendGroupsEvent; /** - * Magic logic to add user groups injected into $this->>info['alwaysActiveGroups'] + * Resolves the frontend user groups a warmup request should be rendered for, + * based on the group ids the FrontendRequestBuilder attached to the request. */ class FrontendUserGroupInjector { @@ -27,16 +28,21 @@ public function __construct(protected LoggerInterface $logger, protected Connect public function frontendUserGroupModifier(ModifyResolvedFrontendGroupsEvent $event): void { $simulationData = $event->getRequest()->getAttribute('b13/warmup'); - if (!is_array($simulationData)) { + if (!is_array($simulationData) || !is_array($simulationData['simulateFrontendUserGroupIds'] ?? null)) { $this->logger->info(self::class . ' was activated, but no user groups were set'); return; } - $userGroups = $this->fetchGroupsFromDatabase($simulationData['simulateFrontendUserGroupIds']); - $event->setGroups($userGroups); + $event->setGroups($this->fetchGroupsFromDatabase($simulationData['simulateFrontendUserGroupIds'])); } + /** + * @param int[] $groupUids + */ private function fetchGroupsFromDatabase(array $groupUids): array { + if ($groupUids === []) { + return []; + } $groupRecords = []; $this->logger->debug('Get usergroups with id: ' . implode(',', $groupUids)); $queryBuilder = $this->connectionPool->getQueryBuilderForTable('fe_groups'); diff --git a/Classes/Command/WarmupCommand.php b/Classes/Command/WarmupCommand.php index 6ee9fd3..462ade8 100644 --- a/Classes/Command/WarmupCommand.php +++ b/Classes/Command/WarmupCommand.php @@ -14,6 +14,7 @@ use B13\Warmup\Service\PageWarmupService; use B13\Warmup\Service\RootlineWarmupService; +use B13\Warmup\Service\WarmupServiceInterface; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; @@ -21,18 +22,21 @@ use Symfony\Component\Console\Style\SymfonyStyle; /** - * Called via cache:warmup + * Called via cache:warmupPages */ class WarmupCommand extends Command { private SymfonyStyle $io; - public function __construct(protected PageWarmupService $pageWarmupService, protected RootlineWarmupService $rootlineWarmupService, ?string $name = null) - { + public function __construct( + protected PageWarmupService $pageWarmupService, + protected RootlineWarmupService $rootlineWarmupService, + ?string $name = null + ) { parent::__construct($name); } - public function configure(): void + protected function configure(): void { $this ->addArgument( @@ -48,37 +52,36 @@ protected function initialize(InputInterface $input, OutputInterface $output): v $this->io = new SymfonyStyle($input, $output); } - /** - * @inheritdoc - */ protected function execute(InputInterface $input, OutputInterface $output): int { $this->io->title('Welcome to the Cache Warmup'); - $type = $input->getArgument('type'); + $type = (string)$input->getArgument('type'); + $services = $this->getWarmupServices($type); + if ($services === []) { + $this->io->error('Unknown type "' . $type . '", use one of "all", "rootline" or "pages".'); + return Command::INVALID; + } - foreach ($this->getWarmupService($type) as $specificType => $service) { + foreach ($services as $specificType => $service) { $this->io->section('Warming up ' . $specificType); - call_user_func_array([$service, 'warmUp'], [$this->io]); + $service->warmUp($this->io); } $this->io->success('All done'); return Command::SUCCESS; } - private function getWarmupService(string $type): iterable + /** + * @return array + */ + private function getWarmupServices(string $type): array { - switch ($type) { - case 'all': - yield 'rootline' => $this->rootlineWarmupService; - yield 'pages' => $this->pageWarmupService; - break; - case 'rootline': - yield 'rootline' => $this->rootlineWarmupService; - break; - case 'pages': - yield 'pages' => $this->pageWarmupService; - break; - } + return match ($type) { + 'all' => ['rootline' => $this->rootlineWarmupService, 'pages' => $this->pageWarmupService], + 'rootline' => ['rootline' => $this->rootlineWarmupService], + 'pages' => ['pages' => $this->pageWarmupService], + default => [], + }; } } diff --git a/Classes/FrontendRequestBuilder.php b/Classes/FrontendRequestBuilder.php index 27afc90..76b5076 100644 --- a/Classes/FrontendRequestBuilder.php +++ b/Classes/FrontendRequestBuilder.php @@ -25,25 +25,31 @@ class FrontendRequestBuilder { public function __construct(protected Application $application, protected LoggerInterface $logger) {} - public function buildRequestForPage(UriInterface $uri, $frontendUserGroups = []): void + /** + * @param int[] $frontendUserGroups + */ + public function buildRequestForPage(UriInterface $uri, array $frontendUserGroups = []): void { $serverParams = [ 'SCRIPT_NAME' => '/index.php', - 'HTTP_HOST' => $uri->getHost(), + 'HTTP_HOST' => $uri->getHost(), 'SERVER_NAME' => $uri->getHost(), 'HTTPS' => $uri->getScheme() === 'https' ? 'on' : 'off', 'REMOTE_ADDR' => '127.0.0.1', ]; - $headers = []; - $serverRequest = new ServerRequest($uri, 'GET', null, $headers, $serverParams); + $serverRequest = new ServerRequest($uri, 'GET', null, [], $serverParams); $serverRequest = $serverRequest->withAttribute('normalizedParams', NormalizedParams::createFromRequest($serverRequest)); $serverRequest = $serverRequest->withAttribute('b13/warmup', [ 'simulateFrontendUserGroupIds' => $frontendUserGroups, ]); try { $this->application->handle($serverRequest); - } catch (\Exception $e) { - $this->logger->error('cannot fetch url ' . (string)$uri); + } catch (\Throwable $e) { + $this->logger->error('cannot fetch url {url}: {message}', [ + 'url' => (string)$uri, + 'message' => $e->getMessage(), + 'exception' => $e, + ]); } } } diff --git a/Classes/Service/PageWarmupService.php b/Classes/Service/PageWarmupService.php index f9e869b..56aab65 100644 --- a/Classes/Service/PageWarmupService.php +++ b/Classes/Service/PageWarmupService.php @@ -14,7 +14,6 @@ use B13\Warmup\FrontendRequestBuilder; use Doctrine\DBAL\ArrayParameterType; -use Doctrine\DBAL\ParameterType; use Psr\Http\Message\UriInterface; use Symfony\Component\Console\Style\SymfonyStyle; use TYPO3\CMS\Core\Database\ConnectionPool; @@ -22,13 +21,27 @@ use TYPO3\CMS\Core\Database\Query\Restriction\HiddenRestriction; use TYPO3\CMS\Core\Database\Query\Restriction\WorkspaceRestriction; use TYPO3\CMS\Core\Domain\Repository\PageRepository; -use TYPO3\CMS\Core\Exception\SiteNotFoundException; use TYPO3\CMS\Core\Site\SiteFinder; use TYPO3\CMS\Core\Utility\GeneralUtility; use TYPO3\CMS\Core\Utility\RootlineUtility; -class PageWarmupService +class PageWarmupService implements WarmupServiceInterface { + /** + * Page types that never render a frontend page on their own. + */ + private const EXCLUDED_DOKTYPES = [ + PageRepository::DOKTYPE_LINK, + PageRepository::DOKTYPE_SHORTCUT, + PageRepository::DOKTYPE_BE_USER_SECTION, + PageRepository::DOKTYPE_MOUNTPOINT, + PageRepository::DOKTYPE_SPACER, + PageRepository::DOKTYPE_SYSFOLDER, + // "Recycler", removed as a constant in v13 and migrated to DOKTYPE_BE_USER_SECTION, + // kept as a literal so v12 installations still skip these pages + 255, + ]; + private SymfonyStyle $io; public function __construct( @@ -42,24 +55,15 @@ public function warmUp(SymfonyStyle $io): void $this->io = $io; // fetch all pages which are not deleted and in live workspace and not one of excluded types - $excludeDocTypes = [ - PageRepository::DOKTYPE_LINK, - PageRepository::DOKTYPE_SHORTCUT, - PageRepository::DOKTYPE_BE_USER_SECTION, - PageRepository::DOKTYPE_MOUNTPOINT, - PageRepository::DOKTYPE_SPACER, - PageRepository::DOKTYPE_SYSFOLDER, - PageRepository::DOKTYPE_RECYCLER, - ]; $queryBuilder = $this->connectionPool ->getQueryBuilderForTable('pages'); $queryBuilder->getRestrictions() ->removeAll() - ->add(GeneralUtility::makeInstance(WorkspaceRestriction::class)) - ->add(GeneralUtility::makeInstance(HiddenRestriction::class)) - ->add(GeneralUtility::makeInstance(DeletedRestriction::class)); + ->add(new WorkspaceRestriction()) + ->add(new HiddenRestriction()) + ->add(new DeletedRestriction()); $statement = $queryBuilder->select('*')->from('pages')->where( - $queryBuilder->expr()->notIn('doktype', $queryBuilder->createNamedParameter($excludeDocTypes, ArrayParameterType::INTEGER)) + $queryBuilder->expr()->notIn('doktype', $queryBuilder->createNamedParameter(self::EXCLUDED_DOKTYPES, ArrayParameterType::INTEGER)) )->executeQuery(); $io->writeln('Starting to request pages at ' . date('d.m.Y H:i:s')); @@ -68,17 +72,17 @@ public function warmUp(SymfonyStyle $io): void while ($pageRecord = $statement->fetchAssociative()) { try { $languageUid = (int)$pageRecord['sys_language_uid']; - $pageUid = $pageRecord['uid']; + $pageUid = (int)$pageRecord['uid']; if ($languageUid > 0) { - $pageUid = $pageRecord['l10n_parent']; + $pageUid = (int)$pageRecord['l10n_parent']; } - $site = GeneralUtility::makeInstance(SiteFinder::class)->getSiteByPageId($pageUid); + $site = $this->siteFinder->getSiteByPageId($pageUid); $siteLanguage = $site->getLanguageById($languageUid); $url = $site->getRouter()->generateUri($pageUid, ['_language' => $siteLanguage]); $this->executeRequestForPageRecord($url, $pageRecord); $requestedPages++; - } catch (SiteNotFoundException $e) { - $io->error('Cache for Page ID ' . $pageRecord['uid'] . ' could not be warmed up'); + } catch (\Throwable $e) { + $io->error('Cache for Page ID ' . $pageRecord['uid'] . ' could not be warmed up: ' . $e->getMessage()); } } @@ -94,16 +98,15 @@ protected function executeRequestForPageRecord(UriInterface $url, array $pageRec protected function resolveRequestedUserGroupsForPage(array $pageRecord): array { - $userGroups = $pageRecord['fe_group']; - $rootLine = GeneralUtility::makeInstance(RootlineUtility::class, $pageRecord['uid'])->get(); + $userGroups = (string)($pageRecord['fe_group'] ?? ''); + $rootLine = GeneralUtility::makeInstance(RootlineUtility::class, (int)$pageRecord['uid'])->get(); foreach ($rootLine as $pageInRootLine) { - if ($pageInRootLine['extendToSubpages']) { - $userGroups .= ',' . $pageInRootLine['fe_group']; + if ($pageInRootLine['extendToSubpages'] ?? false) { + $userGroups .= ',' . (string)($pageInRootLine['fe_group'] ?? ''); } } $userGroups = GeneralUtility::intExplode(',', $userGroups, true); $userGroups = array_filter($userGroups); - $userGroups = array_unique($userGroups); - return $userGroups; + return array_unique($userGroups); } } diff --git a/Classes/Service/RootlineWarmupService.php b/Classes/Service/RootlineWarmupService.php index b02bbf3..8308f7b 100644 --- a/Classes/Service/RootlineWarmupService.php +++ b/Classes/Service/RootlineWarmupService.php @@ -23,9 +23,13 @@ use TYPO3\CMS\Core\Utility\GeneralUtility; use TYPO3\CMS\Core\Utility\RootlineUtility; -class RootlineWarmupService +class RootlineWarmupService implements WarmupServiceInterface { - public function __construct(protected ConnectionPool $connectionPool, protected LoggerInterface $logger) {} + public function __construct( + protected ConnectionPool $connectionPool, + protected Context $context, + protected LoggerInterface $logger + ) {} public function warmUp(SymfonyStyle $io): void { @@ -34,29 +38,29 @@ public function warmUp(SymfonyStyle $io): void ->getQueryBuilderForTable('pages'); $queryBuilder->getRestrictions() ->removeAll() - ->add(GeneralUtility::makeInstance(WorkspaceRestriction::class)) - ->add(GeneralUtility::makeInstance(DeletedRestriction::class)); + ->add(new WorkspaceRestriction()) + ->add(new DeletedRestriction()); $statement = $queryBuilder->select('*')->from('pages')->executeQuery(); while ($pageRecord = $statement->fetchAssociative()) { try { $this->buildRootLineForPage($pageRecord); - } catch (\RuntimeException $e) { - $io->error('Rootline Cache for Page ID ' . $pageRecord['uid'] . ' could not be warmed up'); + } catch (\Throwable $e) { + $io->error('Rootline Cache for Page ID ' . $pageRecord['uid'] . ' could not be warmed up: ' . $e->getMessage()); } } } protected function buildRootLineForPage(array $pageRecord): void { - $context = clone GeneralUtility::makeInstance(Context::class); + $context = clone $this->context; $context->setAspect('visibility', new VisibilityAspect(false, false, false, false)); - $pageUid = $pageRecord['uid']; - if ($pageRecord['sys_language_uid'] > 0) { - $context->setAspect('language', new LanguageAspect($pageRecord['sys_language_uid'])); - $pageUid = $pageRecord['l10n_parent']; + $pageUid = (int)$pageRecord['uid']; + $languageUid = (int)($pageRecord['sys_language_uid'] ?? 0); + if ($languageUid > 0) { + $context->setAspect('language', new LanguageAspect($languageUid)); + $pageUid = (int)$pageRecord['l10n_parent']; } - $rootlineUtility = GeneralUtility::makeInstance(RootlineUtility::class, $pageUid, '', $context); - $this->logger->debug('buildRootLine', ['pageUid' => $pageRecord['uid'], 'cacheIdentifier' => $rootlineUtility->getCacheIdentifier($pageUid)]); - $rootlineUtility->get(); + $this->logger->debug('buildRootLine', ['pageUid' => $pageRecord['uid']]); + GeneralUtility::makeInstance(RootlineUtility::class, $pageUid, '', $context)->get(); } } diff --git a/Classes/Service/WarmupServiceInterface.php b/Classes/Service/WarmupServiceInterface.php new file mode 100644 index 0000000..f8ef2e6 --- /dev/null +++ b/Classes/Service/WarmupServiceInterface.php @@ -0,0 +1,20 @@ + 'b13 GmbH', 'author_email' => 'typo3@b13.com', 'state' => 'stable', - 'version' => '2.0.0', + 'version' => '3.0.0', 'constraints' => [ 'depends' => [ - 'typo3' => '12.0.0-13.4.99', + 'typo3' => '12.4.0-14.3.99', ], 'conflicts' => [ ], diff --git a/ext_localconf.php b/ext_localconf.php deleted file mode 100644 index 2cb2f79..0000000 --- a/ext_localconf.php +++ /dev/null @@ -1,18 +0,0 @@ - 'Add Frontend Groups based on CLI Request Builder', - 'description' => 'Adds frontend usergroups by verifying data from the Frontend Request Builder.', - 'subtype' => 'getGroupsFE', - 'available' => false, - 'priority' => 90, - 'quality' => 90, - 'className' => \B13\Warmup\Authentication\FrontendUserGroupInjector::class, - ] -);