diff --git a/CHANGELOG.md b/CHANGELOG.md index b9bb89a..eecac72 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ ## Unreleased +- **Bugfix "Page cache after a frontend render"**: Rendering a page for content extraction +(`FrontendPageRenderer`, used by `PageContentExtractor`) stored its output in the regular page cache (#43), +which then went to every visitor. From a backend request that output has file URLs prefixed twice +(`//fileadmin/...`), because the backend and the frontend `PublicUrlPrefixer` are both registered, +so visitors got pages with every image broken; from CLI the URLs came out relative. The render now +runs with the page cache disabled. Pages already cached with such URLs stay broken until the page +cache is flushed. + - **Bugfix "Capabilities of static catalogs"**: A Symfony AI bridge with a static model catalog advertises only the capabilities its models have (#41). Every bridge was granted all of them, so a bridge without a single image model was listed with image generation. Catalog models that map to no diff --git a/Classes/Service/FrontendPageRenderer.php b/Classes/Service/FrontendPageRenderer.php index 61313e8..4df9baa 100644 --- a/Classes/Service/FrontendPageRenderer.php +++ b/Classes/Service/FrontendPageRenderer.php @@ -22,6 +22,7 @@ use TYPO3\CMS\Core\Page\PageRenderer; use TYPO3\CMS\Core\Site\SiteFinder; use TYPO3\CMS\Core\Utility\GeneralUtility; +use TYPO3\CMS\Frontend\Cache\CacheInstruction; use TYPO3\CMS\Frontend\Http\Application; /** @@ -91,12 +92,26 @@ private function buildServerRequest(int $pageUid): ServerRequestInterface 'HTTPS' => $uri->getScheme() === 'https' ? 'on' : 'off', ]; - $request = new ServerRequest($uri, 'GET', 'php://input', ['Host' => [$httpHost]], $serverParams); - - return $request + $request = (new ServerRequest($uri, 'GET', 'php://input', ['Host' => [$httpHost]], $serverParams)) ->withAttribute('site', $site) ->withAttribute('language', $site->getDefaultLanguage()) ->withAttribute('normalizedParams', NormalizedParams::createFromServerParams($serverParams)); + + return $this->withPageCacheDisabled($request); + } + + /** + * In-process output differs from what visitors get and must neither reach nor come from the page cache. + */ + private function withPageCacheDisabled(ServerRequestInterface $request): ServerRequestInterface + { + if (!class_exists(CacheInstruction::class)) { + // TYPO3 12.4: the frontend middlewares read the "noCache" attribute + return $request->withAttribute('noCache', true); + } + $cacheInstruction = new CacheInstruction(); + $cacheInstruction->disableCache('EXT:aim: In-process render for content extraction'); + return $request->withAttribute('frontend.cache.instruction', $cacheInstruction); } /** diff --git a/Tests/Functional/Fixtures/Extensions/test_internal_request_context/Configuration/RequestMiddlewares.php b/Tests/Functional/Fixtures/Extensions/test_internal_request_context/Configuration/RequestMiddlewares.php new file mode 100644 index 0000000..2061690 --- /dev/null +++ b/Tests/Functional/Fixtures/Extensions/test_internal_request_context/Configuration/RequestMiddlewares.php @@ -0,0 +1,18 @@ + [ + 'b13/aim-test/internal-request-context' => [ + 'target' => \B13\Aim\Tests\Functional\Fixtures\Frontend\InternalRequestContextMiddleware::class, + 'after' => [ + 'typo3/cms-frontend/backend-user-authentication', + ], + 'before' => [ + 'typo3/json-response/frontend-user-authentication', + 'typo3/json-response/backend-user-authentication', + ], + ], + ], +]; diff --git a/Tests/Functional/Fixtures/Extensions/test_internal_request_context/composer.json b/Tests/Functional/Fixtures/Extensions/test_internal_request_context/composer.json new file mode 100644 index 0000000..e3e3f5a --- /dev/null +++ b/Tests/Functional/Fixtures/Extensions/test_internal_request_context/composer.json @@ -0,0 +1,11 @@ +{ + "name": "b13/aim-test-internal-request-context", + "type": "typo3-cms-extension", + "description": "Fixture: adds the testing context to frontend requests not built by the testing framework.", + "license": "GPL-2.0-or-later", + "extra": { + "typo3/cms": { + "extension-key": "test_internal_request_context" + } + } +} diff --git a/Tests/Functional/Fixtures/Frontend/InternalRequestContextMiddleware.php b/Tests/Functional/Fixtures/Frontend/InternalRequestContextMiddleware.php new file mode 100644 index 0000000..0e5cbd5 --- /dev/null +++ b/Tests/Functional/Fixtures/Frontend/InternalRequestContextMiddleware.php @@ -0,0 +1,35 @@ +getAttribute('typo3.testing.context') === null) { + $request = $request->withAttribute('typo3.testing.context', new InternalRequestContext()); + } + return $handler->handle($request); + } +} diff --git a/Tests/Functional/Fixtures/Frontend/Page.typoscript b/Tests/Functional/Fixtures/Frontend/Page.typoscript new file mode 100644 index 0000000..f008ae6 --- /dev/null +++ b/Tests/Functional/Fixtures/Frontend/Page.typoscript @@ -0,0 +1,4 @@ +page = PAGE +page.10 = TEXT +page.10.field = title +page.10.noTrimWrap = |Rendered by the frontend: || diff --git a/Tests/Functional/Service/FrontendPageRendererTest.php b/Tests/Functional/Service/FrontendPageRendererTest.php new file mode 100644 index 0000000..b222cf3 --- /dev/null +++ b/Tests/Functional/Service/FrontendPageRendererTest.php @@ -0,0 +1,120 @@ + [ + 'caching' => [ + 'cacheConfigurations' => [ + 'pages' => [ + 'backend' => Typo3DatabaseBackend::class, + ], + ], + ], + ], + ]; + + protected function setUp(): void + { + parent::setUp(); + + $this->getConnectionPool()->getConnectionForTable('pages')->insert('pages', [ + 'uid' => 1, + 'pid' => 0, + 'title' => 'Home', + 'slug' => '/', + 'doktype' => 1, + 'is_siteroot' => 1, + ]); + $this->setUpFrontendRootPage(1, ['EXT:aim/Tests/Functional/Fixtures/Frontend/Page.typoscript']); + + $siteDirectory = Environment::getConfigPath() . '/sites/test'; + GeneralUtility::mkdir_deep($siteDirectory); + file_put_contents($siteDirectory . '/config.yaml', implode("\n", [ + 'rootPageId: 1', + 'base: \'https://example.com/\'', + 'languages:', + ' - languageId: 0', + ' title: English', + ' locale: en_US.UTF-8', + ' base: /', + '', + ])); + } + + #[Test] + public function rendersThePageWithoutStoringItInThePageCache(): void + { + $html = $this->createRenderer()->renderPageHtml(1); + + self::assertNotNull($html); + self::assertStringContainsString('Rendered by the frontend: Home', $html); + self::assertSame([], $this->getPageCacheBackend()->findIdentifiersByTag('pageId_1')); + } + + #[Test] + public function rendersThePageWithoutReadingItFromThePageCache(): void + { + $this->executeFrontendSubRequest(new InternalRequest('https://example.com/')); + self::assertNotSame([], $this->getPageCacheBackend()->findIdentifiersByTag('pageId_1')); + // Changing the record directly leaves the cached page stale + $this->getConnectionPool()->getConnectionForTable('pages')->update('pages', ['title' => 'Renamed'], ['uid' => 1]); + // The frontend uses its own CacheManager instance, whose runtime cache still holds the record + GeneralUtility::makeInstance(CacheManager::class)->getCache('runtime')->flush(); + + $html = $this->createRenderer()->renderPageHtml(1); + + self::assertNotNull($html); + self::assertStringContainsString('Rendered by the frontend: Renamed', $html); + } + + private function createRenderer(): FrontendPageRenderer + { + return new FrontendPageRenderer( + $this->get(Application::class), + $this->get(SiteFinder::class), + new NullLogger(), + ); + } + + private function getPageCacheBackend(): TaggableBackendInterface + { + $pageCacheBackend = $this->get(CacheManager::class)->getCache('pages')->getBackend(); + self::assertInstanceOf(TaggableBackendInterface::class, $pageCacheBackend); + return $pageCacheBackend; + } +}