Skip to content

Commit c03b4ca

Browse files
committed
[BUGFIX] Improve PageContext middleware performance
Only initialize PageContext for routes that actually need it: - Modules with page tree navigation component - Routes with 'requestPageContext' option This significantly reduces overhead for AJAX requests, and other backend operations that don't require PageContext. Additionally, PageContextFactory is adjusted to no longer generate fake records for pid=0 scenarios. Resolves: #108289 Releases: main Change-Id: Ie8e17ba64aea3455999e5b67d52a990d65a2ac03 Reviewed-on: https://review.typo3.org/c/Packages/TYPO3.CMS/+/91849 Reviewed-by: Sven Liebert <mail@sven-liebert.de> Tested-by: core-ci <typo3@b13.com> Reviewed-by: Benjamin Kott <benjamin.kott@outlook.com> Tested-by: Benjamin Kott <benjamin.kott@outlook.com> Reviewed-by: Oli Bartsch <bo@cedev.de> Tested-by: Oli Bartsch <bo@cedev.de> Tested-by: Sven Liebert <mail@sven-liebert.de>
1 parent b34a2d3 commit c03b4ca

File tree

8 files changed

+185
-105
lines changed

8 files changed

+185
-105
lines changed

typo3/sysext/backend/Classes/Context/PageContext.php

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@
3333
* This is a DOMAIN object and should NOT contain HTTP infrastructure concerns like ServerRequestInterface.
3434
*
3535
* Access Handling:
36-
* If the user has no access to the requested page, pageId and pageRecord will be null.
36+
* If the user has no access to the requested page, pageRecord will be null.
3737
* Controllers should check $pageContext->isAccessible() before processing.
3838
*
3939
* Usage:
@@ -53,7 +53,7 @@
5353
final readonly class PageContext
5454
{
5555
/**
56-
* @param ?int $pageId Page ID (null if user has no access to requested page)
56+
* @param int $pageId Page ID (always preserved, even if no access)
5757
* @param ?array $pageRecord Page record from readPageAccess (null if no access)
5858
* @param int[] $selectedLanguageIds Selected language IDs (resolved and validated)
5959
* @param PageLanguageInformation $languageInformation Complete language information for this page
@@ -62,7 +62,7 @@
6262
* @param Permission $pagePermissions User's permissions for this page (calculated from backendUser->calcPerms)
6363
*/
6464
public function __construct(
65-
public ?int $pageId,
65+
public int $pageId,
6666
public ?array $pageRecord,
6767
public SiteInterface $site,
6868
public array $rootLine,
@@ -80,7 +80,7 @@ public function __construct(
8080
*/
8181
public function isAccessible(): bool
8282
{
83-
return $this->pageId !== null && $this->pageRecord !== null;
83+
return $this->pageRecord !== null && $this->pagePermissions->showPagePermissionIsGranted();
8484
}
8585

8686
/**

typo3/sysext/backend/Classes/Context/PageContextFactory.php

Lines changed: 44 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ public function __construct(
5151
* Create PageContext from request and page ID.
5252
*
5353
* This method:
54-
* 1. Validates page access (returns context with null pageId/pageRecord if no access)
54+
* 1. Validates page access (returns context with null pageRecord if no access)
5555
* 2. Fetches language information for the page
5656
* 3. Resolves selected languages with fallback chain
5757
* 4. Validates selected languages against existing translations on this page
@@ -67,8 +67,8 @@ public function __construct(
6767
* navigating to PageB without L=1 shows L=0, returning to PageA restores L=1.
6868
*
6969
* Access Handling:
70-
* If the user has no access to the requested page, a PageContext is still returned
71-
* but with pageId=null and pageRecord=null. Controllers should check isAccessible().
70+
* If the user has no access to the requested page or pid=0, a PageContext is still returned,
71+
* while pageRecord mit be null if no access. Controllers should check isAccessible().
7272
*
7373
* @param int $pageId Page ID to create context for
7474
*/
@@ -82,30 +82,23 @@ public function createFromRequest(
8282
throw new SiteNotFoundException('No site found in request', 1731234567);
8383
}
8484

85-
// Check page access (page 0 is special: root/NullSite without page record)
86-
if ($pageId === 0) {
87-
// Page 0 has no page record - create a minimal one for NullSite
88-
$pageRecord = [
89-
'uid' => 0,
90-
'pid' => 0,
91-
'title' => 'Root',
92-
];
93-
} else {
94-
$pageRecord = BackendUtility::readPageAccess($pageId, $backendUser->getPagePermsClause(Permission::PAGE_SHOW));
95-
if ($pageRecord === false) {
96-
// No access - return context with null values (allows controllers to show no-access page)
97-
$languageInformation = $this->languageService->getLanguageInformationForPage(0, $site, $backendUser);
98-
return new PageContext(
99-
pageId: null,
100-
pageRecord: null,
101-
site: $site,
102-
rootLine: [],
103-
pageTsConfig: GeneralUtility::removeDotsFromTS(BackendUtility::getPagesTSconfig(0)),
104-
selectedLanguageIds: [0],
105-
languageInformation: $languageInformation,
106-
pagePermissions: new Permission(0),
107-
);
108-
}
85+
// Check page access
86+
$pageRecord = BackendUtility::readPageAccess($pageId, $backendUser->getPagePermsClause(Permission::PAGE_SHOW)) ?: null;
87+
if ($pageId === 0 || !$pageRecord) {
88+
// Either root page (pid=0) which has no real page record or no access.
89+
// Return context with preserved pageId.
90+
// pageRecord might be ['path' => '/'] for admins or NULL if no access or non-admin
91+
// Still calculate permissions (admins have access to pid=0, editors don't).
92+
return new PageContext(
93+
pageId: $pageId,
94+
pageRecord: $pageRecord,
95+
site: $site,
96+
rootLine: [],
97+
pageTsConfig: GeneralUtility::removeDotsFromTS(BackendUtility::getPagesTSconfig(0)),
98+
selectedLanguageIds: [0],
99+
languageInformation: $this->languageService->getLanguageInformationForPage(0, $site, $backendUser),
100+
pagePermissions: new Permission($backendUser->calcPerms($pageRecord ?: ['uid' => 0])),
101+
);
109102
}
110103

111104
// Get language information FIRST (needed for validation)
@@ -137,19 +130,10 @@ public function createFromRequest(
137130
$moduleDataLanguages
138131
);
139132

140-
// Validate against languages
141-
// - For page 0: accept requested languages (child pages from different sites might have various translations)
142-
// - For other pages: validate against existing translations (ensures getPrimaryLanguageId() is valid)
133+
// Validate against existing translations on this page (ensures getPrimaryLanguageId() is valid)
143134
// Preference is preserved across navigation (only stored when explicitly changed via request)
144-
if ($pageId === 0) {
145-
// Page 0 (root): Accept all requested languages (already permission-filtered)
146-
// Child records might belong to different sites with different language configurations
147-
$validLanguages = $resolvedLanguages;
148-
} else {
149-
// Regular pages: Only show languages that have translations on this page
150-
$existingLanguageIds = $languageInformation->getAllExistingLanguageIds();
151-
$validLanguages = array_intersect($resolvedLanguages, $existingLanguageIds);
152-
}
135+
$existingLanguageIds = $languageInformation->getAllExistingLanguageIds();
136+
$validLanguages = array_intersect($resolvedLanguages, $existingLanguageIds);
153137

154138
// Ensure at least default language if none are valid
155139
if (empty($validLanguages)) {
@@ -168,6 +152,7 @@ public function createFromRequest(
168152
$moduleData->set('languages', $validLanguages);
169153
}
170154

155+
// Create full PageContext for resolved page record
171156
return new PageContext(
172157
pageId: $pageId,
173158
pageRecord: $pageRecord,
@@ -187,8 +172,8 @@ public function createFromRequest(
187172
* without going through the fallback chain.
188173
*
189174
* Access Handling:
190-
* If the user has no access to the requested page, a PageContext is still returned
191-
* but with pageId=null and pageRecord=null. Controllers should check isAccessible().
175+
* If the user has no access to the requested page or pid=0, a PageContext is still returned,
176+
* while pageRecord mit be null if no access. Controllers should check isAccessible().
192177
*/
193178
public function createWithLanguages(
194179
ServerRequestInterface $request,
@@ -201,38 +186,33 @@ public function createWithLanguages(
201186
throw new SiteNotFoundException('No site found in request', 1731234569);
202187
}
203188

204-
// Check page access (page 0 is special: root/NullSite without page record)
205-
if ($pageId === 0) {
206-
// Page 0 has no page record - create a minimal one for NullSite
207-
$pageRecord = ['uid' => 0, 'pid' => 0, 'title' => 'Root'];
208-
} else {
209-
$pageRecord = BackendUtility::readPageAccess($pageId, $backendUser->getPagePermsClause(Permission::PAGE_SHOW));
210-
if ($pageRecord === false) {
211-
// No access - return context with null values (allows controllers to show no-access page)
212-
$languageInformation = $this->languageService->getLanguageInformationForPage(0, $site, $backendUser);
213-
return new PageContext(
214-
pageId: null,
215-
pageRecord: null,
216-
site: $site,
217-
rootLine: [],
218-
pageTsConfig: GeneralUtility::removeDotsFromTS(BackendUtility::getPagesTSconfig(0)),
219-
selectedLanguageIds: array_map('intval', $languageIds),
220-
languageInformation: $languageInformation,
221-
pagePermissions: new Permission(0),
222-
);
223-
}
189+
$pageRecord = BackendUtility::readPageAccess($pageId, $backendUser->getPagePermsClause(Permission::PAGE_SHOW)) ?: null;
190+
if ($pageId === 0 || !$pageRecord) {
191+
// Either root page (pid=0) which has no real page record or no access.
192+
// Return context with preserved pageId.
193+
// pageRecord might be ['path' => '/'] for admins or NULL if no access or non-admin
194+
// Still calculate permissions (admins have access to pid=0, editors don't).
195+
return new PageContext(
196+
pageId: $pageId,
197+
pageRecord: $pageRecord,
198+
site: $site,
199+
rootLine: [],
200+
pageTsConfig: GeneralUtility::removeDotsFromTS(BackendUtility::getPagesTSconfig(0)),
201+
selectedLanguageIds: array_map('intval', $languageIds),
202+
languageInformation: $this->languageService->getLanguageInformationForPage(0, $site, $backendUser),
203+
pagePermissions: new Permission($backendUser->calcPerms($pageRecord ?: ['uid' => 0])),
204+
);
224205
}
225206

226-
$languageInformation = $this->languageService->getLanguageInformationForPage($pageId, $site, $backendUser);
227-
207+
// Create full PageContext for resolved page record
228208
return new PageContext(
229209
pageId: $pageId,
230210
pageRecord: $pageRecord,
231211
site: $site,
232212
rootLine: BackendUtility::BEgetRootLine($pageId),
233213
pageTsConfig: GeneralUtility::removeDotsFromTS(BackendUtility::getPagesTSconfig($pageId)),
234214
selectedLanguageIds: array_map('intval', $languageIds),
235-
languageInformation: $languageInformation,
215+
languageInformation: $this->languageService->getLanguageInformationForPage($pageId, $site, $backendUser),
236216
pagePermissions: new Permission($backendUser->calcPerms($pageRecord)),
237217
);
238218
}

typo3/sysext/backend/Classes/Controller/RecordListController.php

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -122,15 +122,15 @@ public function mainAction(ServerRequestInterface $request): ResponseInterface
122122
// Create updated PageContext with modified languages and update request
123123
$this->pageContext = $this->pageContextFactory->createWithLanguages(
124124
$request,
125-
$this->pageContext->pageId ?? 0,
125+
$this->pageContext->pageId,
126126
$languagesToDisplay,
127127
$backendUser
128128
);
129129
$request = $request->withAttribute('pageContext', $this->pageContext);
130130
}
131131
$this->moduleData->set('languages', $languagesToDisplay);
132132

133-
$siteLanguages = $this->pageContext->site->getAvailableLanguages($backendUser, false, $this->pageContext->pageId ?? 0);
133+
$siteLanguages = $this->pageContext->site->getAvailableLanguages($backendUser, false, $this->pageContext->pageId);
134134
$backendUser->pushModuleData($this->moduleData->getModuleIdentifier(), $this->moduleData->toArray());
135135

136136
// Loading module configuration, clean up settings, current page and page access
@@ -187,7 +187,7 @@ public function mainAction(ServerRequestInterface $request): ResponseInterface
187187
$view = $this->moduleTemplateFactory->create($request);
188188

189189
$tableListHtml = '';
190-
if (!empty($this->pageContext->pageRecord) || ($this->pageContext->pageId === 0 && $search_levels !== 0 && $this->searchTerm !== '')) {
190+
if ($this->pageContext->isAccessible() || ($this->pageContext->pageId === 0 && $search_levels !== 0 && $this->searchTerm !== '')) {
191191
// If there is access to the page or root page is used for searching, then perform actions and render table list.
192192
if ($cmd === 'delete' && $request->getMethod() === 'POST') {
193193
$this->deleteRecords($request, $clipboard);

typo3/sysext/backend/Classes/Middleware/PageContextInitialization.php

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@
2424
use Psr\Log\LoggerAwareInterface;
2525
use Psr\Log\LoggerAwareTrait;
2626
use TYPO3\CMS\Backend\Context\PageContextFactory;
27+
use TYPO3\CMS\Backend\Module\ModuleInterface;
28+
use TYPO3\CMS\Backend\Routing\Route;
2729
use TYPO3\CMS\Backend\Utility\BackendUtility;
2830
use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
2931

@@ -60,6 +62,10 @@ public function __construct(
6062

6163
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
6264
{
65+
if (!$this->requiresPageContext($request)) {
66+
return $handler->handle($request);
67+
}
68+
6369
$backendUser = $request->getAttribute('backend.user', $GLOBALS['BE_USER']);
6470

6571
// Only process if user is authenticated in the backend
@@ -142,4 +148,17 @@ private function getPageIdByRecord(string $table, int $id, bool $ignoreTable = f
142148
}
143149
return $pageId;
144150
}
151+
152+
/**
153+
* Check if the current request requires PageContext initialization.
154+
*
155+
* PageContext is required when:
156+
* 1. Module uses the page tree navigation component
157+
* 2. Route explicitly has 'requestPageContext' option set to true
158+
*/
159+
private function requiresPageContext(ServerRequestInterface $request): bool
160+
{
161+
return ((($module = $request->getAttribute('module')) instanceof ModuleInterface) && $module->getNavigationComponent() === '@typo3/backend/tree/page-tree-element')
162+
|| ((($route = $request->getAttribute('route')) instanceof Route) && $route->getOption('requestPageContext'));
163+
}
145164
}

typo3/sysext/backend/Configuration/Backend/Routes.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -276,6 +276,9 @@
276276
'module' => true,
277277
],
278278
],
279+
'options' => [
280+
'requestPageContext' => true,
281+
],
279282
],
280283

281284
// Image processing

typo3/sysext/backend/Tests/Functional/Context/PageContextTest.php

Lines changed: 46 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -159,8 +159,35 @@ public function createsContextForPageZero(): void
159159
$this->backendUser
160160
);
161161

162+
// Page 0 (root) for admin returns page record with path
162163
self::assertSame(0, $pageContext->pageId);
163-
self::assertSame(['uid' => 0, 'pid' => 0, 'title' => 'Root'], $pageContext->pageRecord);
164+
self::assertSame(['_thePath' => '/'], $pageContext->pageRecord);
165+
self::assertTrue($pageContext->isAccessible());
166+
self::assertInstanceOf(NullSite::class, $pageContext->site);
167+
}
168+
169+
#[Test]
170+
public function createsContextForPageZeroAsEditor(): void
171+
{
172+
// Set up non-admin user (editor)
173+
$editorUser = $this->setUpBackendUser(2);
174+
175+
$request = (new ServerRequest('https://example.com/'))
176+
->withAttribute('applicationType', SystemEnvironmentBuilder::REQUESTTYPE_BE)
177+
->withAttribute('site', new NullSite())
178+
->withAttribute('normalizedParams', NormalizedParams::createFromRequest(new ServerRequest()));
179+
180+
$pageContextFactory = $this->get(PageContextFactory::class);
181+
$pageContext = $pageContextFactory->createFromRequest(
182+
$request,
183+
0,
184+
$editorUser
185+
);
186+
187+
// Page 0 (root) for editor has no access
188+
self::assertSame(0, $pageContext->pageId);
189+
self::assertNull($pageContext->pageRecord);
190+
self::assertFalse($pageContext->isAccessible());
164191
self::assertInstanceOf(NullSite::class, $pageContext->site);
165192
}
166193

@@ -226,36 +253,6 @@ public function retrievesDisableLanguagesTSconfigCorrectlyForPageZero(): void
226253
self::assertSame('', $disableLanguages);
227254
}
228255

229-
#[Test]
230-
public function pageZeroValidatesAgainstSiteLanguagesNotExistingTranslations(): void
231-
{
232-
$site = new Site('test-site', 1, [
233-
'base' => 'https://example.com/',
234-
'languages' => [
235-
['languageId' => 0, 'locale' => 'en-US', 'base' => '/', 'title' => 'English'],
236-
['languageId' => 1, 'locale' => 'de-DE', 'base' => '/de', 'title' => 'German'],
237-
['languageId' => 2, 'locale' => 'fr-FR', 'base' => '/fr', 'title' => 'French'],
238-
],
239-
]);
240-
241-
$request = (new ServerRequest('https://example.com/'))
242-
->withAttribute('applicationType', SystemEnvironmentBuilder::REQUESTTYPE_BE)
243-
->withAttribute('site', $site)
244-
->withAttribute('normalizedParams', NormalizedParams::createFromRequest(new ServerRequest()))
245-
->withQueryParams(['languages' => [0, 1, 2]]);
246-
247-
$pageContextFactory = $this->get(PageContextFactory::class);
248-
$pageContext = $pageContextFactory->createFromRequest(
249-
$request,
250-
0, // Page 0 has no translations, but child records might have
251-
$this->backendUser
252-
);
253-
254-
// Page 0 should allow all site-configured languages (for displaying child records)
255-
// NOT just languages with translations (page 0 itself has no translations)
256-
self::assertSame([0, 1, 2], $pageContext->selectedLanguageIds);
257-
}
258-
259256
#[Test]
260257
public function preservesLanguagePreferenceAcrossPageNavigation(): void
261258
{
@@ -733,7 +730,7 @@ public function returnsNullableContextForNoAccessPage(): void
733730

734731
// Should return context with null values instead of throwing exception
735732
self::assertFalse($pageContext->isAccessible());
736-
self::assertNull($pageContext->pageId);
733+
self::assertEquals(1, $pageContext->pageId, 'PageId is always preserved!');
737734
self::assertNull($pageContext->pageRecord);
738735
self::assertSame([], $pageContext->rootLine);
739736
self::assertSame([0], $pageContext->selectedLanguageIds);
@@ -764,7 +761,23 @@ public function contextProvidesPagePermissions(): void
764761
}
765762

766763
#[Test]
767-
public function contextProvidesPagePermissionsForPageZero(): void
764+
public function contextProvidesPagePermissionsForPageZeroAndAdmin(): void
765+
{
766+
$request = (new ServerRequest('https://example.com/'))
767+
->withAttribute('applicationType', SystemEnvironmentBuilder::REQUESTTYPE_BE)
768+
->withAttribute('site', new NullSite())
769+
->withAttribute('normalizedParams', NormalizedParams::createFromRequest(new ServerRequest()));
770+
771+
$pageContextFactory = $this->get(PageContextFactory::class);
772+
$pageContext = $pageContextFactory->createFromRequest($request, 0, $this->backendUser);
773+
774+
// Admin user has permissions on page 0 (minimal page record)
775+
self::assertTrue($pageContext->pagePermissions->showPagePermissionIsGranted());
776+
self::assertTrue($pageContext->pagePermissions->editPagePermissionIsGranted());
777+
}
778+
779+
#[Test]
780+
public function contextProvidesPagePermissionsForPageZeroAndNonAdmin(): void
768781
{
769782
$editorUser = $this->setUpBackendUser(2);
770783
$request = (new ServerRequest('https://example.com/'))

0 commit comments

Comments
 (0)