From c98ddc1e3680bdfe56083d38187ba02c3f4022d9 Mon Sep 17 00:00:00 2001 From: David Steeb Date: Tue, 13 Oct 2026 14:12:16 +0200 Subject: [PATCH 1/2] [BUGFIX] Render pages for content extraction uncached FrontendPageRenderer sent its in-process request through the regular page cache. Called from a backend request, the output has file URLs prefixed twice ("//fileadmin/...") because the backend and the frontend PublicUrlPrefixer are both registered; from CLI they come out relative. That page was then served to every visitor. Disable the page cache for the render request (CacheInstruction on TYPO3 13/14, the noCache attribute on 12.4). Resolves: #43 --- CHANGELOG.md | 8 ++ Classes/Service/FrontendPageRenderer.php | 28 +++++- .../Configuration/RequestMiddlewares.php | 18 ++++ .../composer.json | 11 +++ .../InternalRequestContextMiddleware.php | 35 +++++++ .../Fixtures/Frontend/Page.typoscript | 3 + .../Service/FrontendPageRendererTest.php | 95 +++++++++++++++++++ 7 files changed, 195 insertions(+), 3 deletions(-) create mode 100644 Tests/Functional/Fixtures/Extensions/test_internal_request_context/Configuration/RequestMiddlewares.php create mode 100644 Tests/Functional/Fixtures/Extensions/test_internal_request_context/composer.json create mode 100644 Tests/Functional/Fixtures/Frontend/InternalRequestContextMiddleware.php create mode 100644 Tests/Functional/Fixtures/Frontend/Page.typoscript create mode 100644 Tests/Functional/Service/FrontendPageRendererTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index b9bb89a..55649c6 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, +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..b165bb4 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,33 @@ 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); + } + + /** + * The render runs inside whatever request called it, usually a backend + * request. Its output is not what a visitor would get: in a backend + * request TYPO3 has already registered the backend PublicUrlPrefixer, + * the frontend RequestHandler adds its own, and both prefix file URLs, + * so they come out as "//fileadmin/...". From CLI the site path is empty + * and they come out relative. The request carries no cookies, so TYPO3 + * would store that page in the regular page cache and serve it to every + * visitor. Rendering uncached keeps it out of there. + */ + 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..71fb9af --- /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: gives frontend requests that were not built by the testing framework an empty internal request context, so its json_response middlewares let them through. Used by FrontendPageRendererTest, whose renderer builds its own request.", + "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..da0ce5c --- /dev/null +++ b/Tests/Functional/Fixtures/Frontend/Page.typoscript @@ -0,0 +1,3 @@ +page = PAGE +page.10 = TEXT +page.10.value = Rendered by the frontend diff --git a/Tests/Functional/Service/FrontendPageRendererTest.php b/Tests/Functional/Service/FrontendPageRendererTest.php new file mode 100644 index 0000000..cecc246 --- /dev/null +++ b/Tests/Functional/Service/FrontendPageRendererTest.php @@ -0,0 +1,95 @@ + [ + '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 + { + $renderer = new FrontendPageRenderer( + $this->get(Application::class), + $this->get(SiteFinder::class), + new NullLogger(), + ); + + $html = $renderer->renderPageHtml(1); + + self::assertNotNull($html); + self::assertStringContainsString('Rendered by the frontend', $html); + $pageCacheBackend = $this->get(CacheManager::class)->getCache('pages')->getBackend(); + self::assertInstanceOf(TaggableBackendInterface::class, $pageCacheBackend); + self::assertSame([], $pageCacheBackend->findIdentifiersByTag('pageId_1')); + } +} From 432ba306d305cb6c3e106cfd8a2adc75d1059f76 Mon Sep 17 00:00:00 2001 From: Oliver Bartsch Date: Tue, 13 Oct 2026 15:45:42 +0200 Subject: [PATCH 2/2] [TASK] Cover the page cache read in the frontend render test The render bypasses the page cache for reading as well, so a page cached by a visitor's request is rendered again. A test now covers that next to the existing write test. Shorten the docblock of withPageCacheDisabled() to its contract, reference the issue in the changelog entry and shorten the description of the test fixture extension. Related: #43 Signed-off-by: Oliver Bartsch --- CHANGELOG.md | 2 +- Classes/Service/FrontendPageRenderer.php | 9 +---- .../composer.json | 2 +- .../Fixtures/Frontend/Page.typoscript | 3 +- .../Service/FrontendPageRendererTest.php | 37 ++++++++++++++++--- 5 files changed, 36 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 55649c6..eecac72 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,7 @@ ## 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, +(`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 diff --git a/Classes/Service/FrontendPageRenderer.php b/Classes/Service/FrontendPageRenderer.php index b165bb4..4df9baa 100644 --- a/Classes/Service/FrontendPageRenderer.php +++ b/Classes/Service/FrontendPageRenderer.php @@ -101,14 +101,7 @@ private function buildServerRequest(int $pageUid): ServerRequestInterface } /** - * The render runs inside whatever request called it, usually a backend - * request. Its output is not what a visitor would get: in a backend - * request TYPO3 has already registered the backend PublicUrlPrefixer, - * the frontend RequestHandler adds its own, and both prefix file URLs, - * so they come out as "//fileadmin/...". From CLI the site path is empty - * and they come out relative. The request carries no cookies, so TYPO3 - * would store that page in the regular page cache and serve it to every - * visitor. Rendering uncached keeps it out of there. + * In-process output differs from what visitors get and must neither reach nor come from the page cache. */ private function withPageCacheDisabled(ServerRequestInterface $request): ServerRequestInterface { diff --git a/Tests/Functional/Fixtures/Extensions/test_internal_request_context/composer.json b/Tests/Functional/Fixtures/Extensions/test_internal_request_context/composer.json index 71fb9af..e3e3f5a 100644 --- a/Tests/Functional/Fixtures/Extensions/test_internal_request_context/composer.json +++ b/Tests/Functional/Fixtures/Extensions/test_internal_request_context/composer.json @@ -1,7 +1,7 @@ { "name": "b13/aim-test-internal-request-context", "type": "typo3-cms-extension", - "description": "Fixture: gives frontend requests that were not built by the testing framework an empty internal request context, so its json_response middlewares let them through. Used by FrontendPageRendererTest, whose renderer builds its own request.", + "description": "Fixture: adds the testing context to frontend requests not built by the testing framework.", "license": "GPL-2.0-or-later", "extra": { "typo3/cms": { diff --git a/Tests/Functional/Fixtures/Frontend/Page.typoscript b/Tests/Functional/Fixtures/Frontend/Page.typoscript index da0ce5c..f008ae6 100644 --- a/Tests/Functional/Fixtures/Frontend/Page.typoscript +++ b/Tests/Functional/Fixtures/Frontend/Page.typoscript @@ -1,3 +1,4 @@ page = PAGE page.10 = TEXT -page.10.value = Rendered by the frontend +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 index cecc246..b222cf3 100644 --- a/Tests/Functional/Service/FrontendPageRendererTest.php +++ b/Tests/Functional/Service/FrontendPageRendererTest.php @@ -22,6 +22,7 @@ use TYPO3\CMS\Core\Site\SiteFinder; use TYPO3\CMS\Core\Utility\GeneralUtility; use TYPO3\CMS\Frontend\Http\Application; +use TYPO3\TestingFramework\Core\Functional\Framework\Frontend\InternalRequest; use TYPO3\TestingFramework\Core\Functional\FunctionalTestCase; final class FrontendPageRendererTest extends FunctionalTestCase @@ -78,18 +79,42 @@ protected function setUp(): void #[Test] public function rendersThePageWithoutStoringItInThePageCache(): void { - $renderer = new FrontendPageRenderer( + $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(), ); + } - $html = $renderer->renderPageHtml(1); - - self::assertNotNull($html); - self::assertStringContainsString('Rendered by the frontend', $html); + private function getPageCacheBackend(): TaggableBackendInterface + { $pageCacheBackend = $this->get(CacheManager::class)->getCache('pages')->getBackend(); self::assertInstanceOf(TaggableBackendInterface::class, $pageCacheBackend); - self::assertSame([], $pageCacheBackend->findIdentifiersByTag('pageId_1')); + return $pageCacheBackend; } }