Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions Classes/IndexQueue/IndexingService.php
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
use TYPO3\CMS\Core\Http\ServerRequest;
use TYPO3\CMS\Core\Page\AssetCollector;
use TYPO3\CMS\Core\Page\PageRenderer;
use TYPO3\CMS\Core\PageTitle\PageTitleProviderManager;
use TYPO3\CMS\Core\Routing\InvalidRouteArgumentsException;
use TYPO3\CMS\Core\Site\SiteFinder;
use TYPO3\CMS\Core\Utility\GeneralUtility;
Expand Down Expand Up @@ -286,6 +287,10 @@ protected function executeSubRequest(
// - AssetCollector and PageRenderer are shared singletons whose
// state is mutated by the frontend rendering chain; without
// restore the BE module loses its registered CSS/JS.
// - PageTitleProviderManager is a shared singleton whose
// per-provider title cache is filled during rendering; without
// a reset, the next sub-request can fall back to the previous
// page's titles and index them into the wrong document.
// CWD is also pinned to the document root so third-party code
// using relative paths behaves like in a real web request.
$previousWorkingDirectory = getcwd();
Expand All @@ -304,6 +309,9 @@ protected function executeSubRequest(
$pageRenderer = GeneralUtility::makeInstance(PageRenderer::class);
$previousAssetCollectorState = $assetCollector->getState();
$previousPageRendererState = $pageRenderer->getState();
$pageTitleProviderManager = GeneralUtility::makeInstance(PageTitleProviderManager::class);
$previousPageTitleCache = $pageTitleProviderManager->getPageTitleCache();
$pageTitleProviderManager->setPageTitleCache([]);
chdir(Environment::getPublicPath());
try {
$response = $this->frontendApplication->handle($request);
Expand All @@ -326,6 +334,7 @@ protected function executeSubRequest(
}
$assetCollector->updateState($previousAssetCollectorState);
$pageRenderer->updateState($previousPageRendererState);
$pageTitleProviderManager->setPageTitleCache($previousPageTitleCache);
}

$statusCode = $response->getStatusCode();
Expand Down
25 changes: 22 additions & 3 deletions Classes/Typo3PageContentExtractor.php
Original file line number Diff line number Diff line change
Expand Up @@ -129,15 +129,34 @@ public function getIndexableContent(): string
}

/**
* Retrieves the page's title by checking the indexedDocTitle, altPageTitle,
* and regular page title - in that order.
* Retrieves the page's title from the PageTitleProviderManager, seeded
* with the current page's persisted title state.
*
* The PageTitleProviderManager is a stateful singleton. During in-process
* indexing many pages are rendered within the same PHP process, so its
* internal per-provider cache still holds the titles of the previously
* indexed page. Without re-seeding, getTitle() would fall back to that
* stale state whenever a provider yields no title for the current page —
* e.g. when the page was served from the page cache (so
* generatePageTitle() never ran) and has no own seo_title — indexing
* another page's title into this page's document.
*
* Seeding from the "frontend.page.parts" request attribute mirrors what
* core's RequestHandler::generatePageTitle() does before consulting the
* manager: for freshly rendered pages it contains the titles generated
* during this sub-request, for cached pages the state restored from the
* page's own cache row.
*
* @return string the page's title
*/
public function getPageTitle(): string
{
$request = $GLOBALS['TYPO3_REQUEST'];
$providerManager = GeneralUtility::makeInstance(PageTitleProviderManager::class);
return $providerManager->getTitle($GLOBALS['TYPO3_REQUEST']);
$providerManager->setPageTitleCache(
$request->getAttribute('frontend.page.parts')?->getPageTitle() ?? [],
);
return $providerManager->getTitle($request);
}

/**
Expand Down
139 changes: 139 additions & 0 deletions Tests/Unit/Typo3PageContentExtractorPageTitleTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
<?php

/*
* 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!
*/

namespace ApacheSolrForTypo3\Solr\Tests\Unit;

use ApacheSolrForTypo3\Solr\Typo3PageContentExtractor;
use PHPUnit\Framework\Attributes\Test;
use Psr\Log\NullLogger;
use Symfony\Component\DependencyInjection\Container;
use TYPO3\CMS\Core\Http\ServerRequest;
use TYPO3\CMS\Core\PageTitle\PageTitleProviderManager;
use TYPO3\CMS\Core\PageTitle\RecordPageTitleProvider;
use TYPO3\CMS\Core\PageTitle\RecordTitleProvider;
use TYPO3\CMS\Core\Service\DependencyOrderingService;
use TYPO3\CMS\Core\TypoScript\AST\Node\RootNode;
use TYPO3\CMS\Core\TypoScript\FrontendTypoScript;
use TYPO3\CMS\Core\TypoScript\TypoScriptService;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Frontend\Page\PageInformation;
use TYPO3\CMS\Frontend\Page\PageParts;

/**
* Tests that page title extraction does not leak title state between
* in-process indexing sub-requests.
*
* The PageTitleProviderManager is a stateful singleton: during indexing many
* pages are rendered within the same PHP process, and its per-provider cache
* still holds the titles of the previously indexed page when the next page's
* document is built.
*/
class Typo3PageContentExtractorPageTitleTest extends SetUpUnitTestCase
{
protected PageTitleProviderManager $pageTitleProviderManager;

protected function setUp(): void
{
parent::setUp();

$container = new Container();
$container->set(RecordTitleProvider::class, new RecordTitleProvider());
$container->set(RecordPageTitleProvider::class, new RecordPageTitleProvider());

$this->pageTitleProviderManager = new PageTitleProviderManager(
$container,
new DependencyOrderingService(),
new TypoScriptService(),
new NullLogger(),
);
GeneralUtility::setSingletonInstance(PageTitleProviderManager::class, $this->pageTitleProviderManager);
}

protected function tearDown(): void
{
unset($GLOBALS['TYPO3_REQUEST']);
parent::tearDown();
}

#[Test]
public function getPageTitleDoesNotLeakTitleStateOfPreviouslyIndexedPage(): void
{
// Stale cache entry left behind by the previously indexed page.
$this->pageTitleProviderManager->setPageTitleCache([
RecordTitleProvider::class => 'Title of the previously indexed page',
]);

$GLOBALS['TYPO3_REQUEST'] = $this->buildRequest(new PageParts());

$contentExtractor = GeneralUtility::makeInstance(
Typo3PageContentExtractor::class,
'<html><body>content</body></html>',
);

self::assertSame('Title of the current page', $contentExtractor->getPageTitle());
}

#[Test]
public function getPageTitleUsesPersistedTitleStateOfCurrentPage(): void
{
// Stale cache entry left behind by the previously indexed page.
$this->pageTitleProviderManager->setPageTitleCache([
RecordTitleProvider::class => 'Title of the previously indexed page',
]);

// Persisted title state of the current page, as restored from the
// page's own cache row when it is served from the page cache.
$pageParts = new PageParts();
$pageParts->setPageTitle([
RecordTitleProvider::class => 'Persisted title of the current page',
]);

$GLOBALS['TYPO3_REQUEST'] = $this->buildRequest($pageParts);

$contentExtractor = GeneralUtility::makeInstance(
Typo3PageContentExtractor::class,
'<html><body>content</body></html>',
);

self::assertSame('Persisted title of the current page', $contentExtractor->getPageTitle());
}

protected function buildRequest(PageParts $pageParts): ServerRequest
{
$frontendTypoScript = new FrontendTypoScript(new RootNode(), [], [], []);
$frontendTypoScript->setConfigArray([
'pageTitleProviders.' => [
'recordTitle.' => [
'provider' => RecordTitleProvider::class,
'before' => 'record',
],
'record.' => [
'provider' => RecordPageTitleProvider::class,
],
],
]);

$pageInformation = new PageInformation();
$pageInformation->setPageRecord([
'uid' => 4711,
'title' => 'Title of the current page',
]);

return (new ServerRequest('https://example.com/'))
->withAttribute('frontend.typoscript', $frontendTypoScript)
->withAttribute('frontend.page.parts', $pageParts)
->withAttribute('frontend.page.information', $pageInformation);
}
}