Skip to content

Commit bfeba38

Browse files
committed
[FEATURE] core content element restriction
https://review.typo3.org/c/Packages/TYPO3.CMS/+/92260 this replace EXT:content_defender integratrion Note: we keep the maxitems feature Note: we replace the core datahandler hooks compete (may be this will change in future)
1 parent 9fe832a commit bfeba38

File tree

18 files changed

+436
-37
lines changed

18 files changed

+436
-37
lines changed

.github/workflows/ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ jobs:
5858
run: Build/Scripts/runTests.sh -p ${{ matrix.php }} -t ${{ matrix.TYPO3 }} -s acceptance -- --fail-fast
5959
if: matrix.TYPO3 != '14'
6060
- name: Acceptance Tests 14
61-
run: Build/Scripts/runTests.sh -p ${{ matrix.php }} -t ${{ matrix.TYPO3 }} -s acceptance -- --fail-fast --skip-group=content_defender
61+
run: Build/Scripts/runTests.sh -p ${{ matrix.php }} -t ${{ matrix.TYPO3 }} -s acceptance -- --fail-fast
6262
if: matrix.TYPO3 == '14'
6363
- name: Archive acceptance tests results
6464
uses: actions/upload-artifact@v4

Build/phpstan11-7.4.neon

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,4 +17,6 @@ parameters:
1717
- %currentWorkingDirectory%/Classes/Listener/PageTsConfig.php
1818
- %currentWorkingDirectory%/Classes/Listener/PageContentPreviewRendering.php
1919
- %currentWorkingDirectory%/Classes/Domain/Core/RecordWithRenderedGrid.php
20+
- %currentWorkingDirectory%/Classes/Listener/ManipulateBackendLayoutColPosConfigurationForPage.php
21+
- %currentWorkingDirectory%/Classes/Hooks/Datahandler/ContentElementRestriction
2022

Build/phpstan11.neon

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,4 +17,6 @@ parameters:
1717
- %currentWorkingDirectory%/Classes/Listener/PageTsConfig.php
1818
- %currentWorkingDirectory%/Classes/Listener/PageContentPreviewRendering.php
1919
- %currentWorkingDirectory%/Classes/Domain/Core/RecordWithRenderedGrid.php
20+
- %currentWorkingDirectory%/Classes/Listener/ManipulateBackendLayoutColPosConfigurationForPage.php
21+
- %currentWorkingDirectory%/Classes/Hooks/Datahandler/ContentElementRestriction
2022

Build/phpstan12.neon

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,5 +14,7 @@ parameters:
1414
- %currentWorkingDirectory%/Tests/Unit/Hooks/UsedRecordsTest.php
1515
- %currentWorkingDirectory%/Classes/Domain/Core/RecordWithRenderedGrid.php
1616
- %currentWorkingDirectory%/Classes/Listener/PageContentPreviewRendering.php
17+
- %currentWorkingDirectory%/Classes/Listener/ManipulateBackendLayoutColPosConfigurationForPage.php
18+
- %currentWorkingDirectory%/Classes/Hooks/Datahandler/ContentElementRestriction
1719

1820

Build/phpstan13.neon

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,4 +15,6 @@ parameters:
1515
- %currentWorkingDirectory%/Classes/Hooks/WizardItems.php
1616
- %currentWorkingDirectory%/Classes/Listener/LegacyPageTsConfig.php
1717
- %currentWorkingDirectory%/Classes/Listener/PageContentPreviewRendering.php
18+
- %currentWorkingDirectory%/Classes/Listener/ManipulateBackendLayoutColPosConfigurationForPage.php
19+
- %currentWorkingDirectory%/Classes/Hooks/Datahandler/ContentElementRestriction
1820

Classes/Hooks/Datahandler/CommandMapPostProcessingHook.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,7 @@ protected function copyOrMoveChildren(int $origUid, int $newId, int $containerId
125125
// when moving or copy a container into other language the other language is returned
126126
$container = $this->containerFactory->buildContainer($origUid);
127127
(GeneralUtility::makeInstance(DatahandlerProcess::class))->startContainerProcess($origUid);
128+
(GeneralUtility::makeInstance(DatahandlerProcess::class))->lockContentElementRestrictions();
128129
$children = [];
129130
$colPosVals = $container->getChildrenColPos();
130131
foreach ($colPosVals as $colPos) {
@@ -177,6 +178,7 @@ protected function copyOrMoveChildren(int $origUid, int $newId, int $containerId
177178
}
178179
}
179180
(GeneralUtility::makeInstance(DatahandlerProcess::class))->endContainerProcess($origUid);
181+
(GeneralUtility::makeInstance(DatahandlerProcess::class))->unlockContentElementRestrictions();
180182
} catch (Exception $e) {
181183
// nothing todo
182184
}
Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace B13\Container\Hooks\Datahandler\ContentElementRestriction;
6+
7+
/*
8+
* This file is part of TYPO3 CMS-based extension "container" by b13.
9+
*
10+
* It is free software; you can redistribute it and/or modify it under
11+
* the terms of the GNU General Public License, either version 2
12+
* of the License, or any later version.
13+
*/
14+
15+
use B13\Container\Domain\Factory\ContainerFactory;
16+
use B13\Container\Domain\Factory\Exception;
17+
use B13\Container\Hooks\Datahandler\DatahandlerProcess;
18+
use B13\Container\Tca\Registry;
19+
use Symfony\Component\DependencyInjection\Attribute\Autoconfigure;
20+
use TYPO3\CMS\Backend\Utility\BackendUtility;
21+
use TYPO3\CMS\Backend\View\BackendLayoutView;
22+
use TYPO3\CMS\Core\DataHandling\DataHandler;
23+
use TYPO3\CMS\Core\Utility\GeneralUtility;
24+
use TYPO3\CMS\Core\Utility\MathUtility;
25+
26+
#[Autoconfigure(public: true)]
27+
class DataHandlerHook
28+
{
29+
protected $lockDatamapHook = false;
30+
31+
public function __construct(
32+
private BackendLayoutView $backendLayoutView,
33+
private DatahandlerProcess $datahandlerProcess,
34+
private ContainerFactory $containerFactory,
35+
private Registry $registry
36+
) {
37+
}
38+
39+
public function processCmdmap_beforeStart(DataHandler $dataHandler): void
40+
{
41+
$cmdmap = $dataHandler->cmdmap;
42+
if (isset($cmdmap['pages'])) {
43+
$this->lockDatamapHook = true;
44+
}
45+
if (empty($cmdmap['tt_content']) || $dataHandler->bypassAccessCheckForRecords) {
46+
return;
47+
}
48+
$this->lockDatamapHook = true;
49+
if ($this->datahandlerProcess->areContentElementRestrictionsLooked()) {
50+
return;
51+
}
52+
foreach ($cmdmap['tt_content'] as $id => $incomingFieldArray) {
53+
foreach ($incomingFieldArray as $command => $value) {
54+
if (!in_array($command, ['copy', 'move'], true)) {
55+
continue;
56+
}
57+
$currentRecord = BackendUtility::getRecord('tt_content', $id);
58+
59+
// EXT:container start
60+
if (
61+
(!empty($value['update'])) &&
62+
isset($value['update']['colPos']) &&
63+
$value['update']['colPos'] > 0 &&
64+
isset($value['update']['tx_container_parent']) &&
65+
$value['update']['tx_container_parent'] > 0 &&
66+
MathUtility::canBeInterpretedAsInteger($id)
67+
) {
68+
$colPos = (int)$value['update']['colPos'];
69+
if (!empty($currentRecord['CType'])) {
70+
if ($this->checkContainerCType((int)$value['update']['tx_container_parent'], $currentRecord['CType'], (int)$value['update']['colPos']) === false) {
71+
// Not allowed to move or copy to target. Unset this command and create a log entry which may be turned into a notification when called by BE.
72+
unset($dataHandler->cmdmap['tt_content'][$id]);
73+
$dataHandler->log('tt_content', $id, 1, null, 1, 'The command "%s" for record "tt_content:%s" with CType "%s" to colPos "%s" couldn\'t be executed due to disallowed value(s).', null, [$command, $id, $currentRecord['CType'], $colPos]);
74+
}
75+
}
76+
$useChildId = null;
77+
if ($command === 'move') {
78+
$useChildId = $id;
79+
}
80+
if ($this->checkContainerMaxItems((int)$value['update']['tx_container_parent'], (int)$value['update']['colPos'], $useChildId)) {
81+
unset($dataHandler->cmdmap['tt_content'][$id]);
82+
$dataHandler->log('tt_content', $id, 1, null, 1, 'The command "%s" for record "tt_content:%s" to colPos "%s" couldn\'t be executed due to maxitems reached.', null, [$command, $id, $colPos]);
83+
}
84+
return;
85+
}
86+
// EXT:container end
87+
88+
if (empty($currentRecord['CType'] ?? '')) {
89+
continue;
90+
}
91+
if (is_array($value) && !empty($value['action']) && $value['action'] === 'paste' && isset($value['update']['colPos'])) {
92+
// Moving / pasting to a new colPos on a potentially different page
93+
$pageId = (int)$value['target'];
94+
$colPos = (int)$value['update']['colPos'];
95+
} else {
96+
$pageId = (int)$value;
97+
$colPos = (int)$currentRecord['colPos'];
98+
}
99+
if ($pageId < 0) {
100+
$targetRecord = BackendUtility::getRecord('tt_content', abs($pageId));
101+
$pageId = (int)$targetRecord['pid'];
102+
$colPos = (int)$targetRecord['colPos'];
103+
}
104+
105+
$backendLayout = $this->backendLayoutView->getBackendLayoutForPage($pageId);
106+
$columnConfiguration = $this->backendLayoutView->getColPosConfigurationForPage($backendLayout, $colPos, $pageId);
107+
$allowedContentElementsInTargetColPos = GeneralUtility::trimExplode(',', $columnConfiguration['allowedContentTypes'] ?? '', true);
108+
$disallowedContentElementsInTargetColPos = GeneralUtility::trimExplode(',', $columnConfiguration['disallowedContentTypes'] ?? '', true);
109+
if ((!empty($allowedContentElementsInTargetColPos) && !in_array($currentRecord['CType'], $allowedContentElementsInTargetColPos, true))
110+
|| (!empty($disallowedContentElementsInTargetColPos) && in_array($currentRecord['CType'], $disallowedContentElementsInTargetColPos, true))
111+
) {
112+
// Not allowed to move or copy to target. Unset this command and create a log entry which may be turned into a notification when called by BE.
113+
unset($dataHandler->cmdmap['tt_content'][$id]);
114+
$dataHandler->log('tt_content', $id, 1, null, 1, 'The command "%s" for record "tt_content:%s" with CType "%s" to colPos "%s" couldn\'t be executed due to disallowed value(s).', null, [$command, $id, $currentRecord['CType'], $colPos]);
115+
}
116+
}
117+
}
118+
}
119+
120+
protected function checkContainerMaxItems(int $containerId, int $colPos, ?int $childUid = null): bool
121+
{
122+
try {
123+
$container = $this->containerFactory->buildContainer($containerId);
124+
$columnConfiguration = $this->registry->getContentDefenderConfiguration($container->getCType(), $colPos);
125+
if (($columnConfiguration['maxitems'] ?? 0) === 0) {
126+
return false;
127+
}
128+
$childrenOfColumn = $container->getChildrenByColPos($colPos);
129+
$count = count($childrenOfColumn);
130+
if ($childUid !== null && $container->hasChildInColPos($colPos, $childUid)) {
131+
$count--;
132+
}
133+
return $count >= $columnConfiguration['maxitems'];
134+
} catch (Exception) {
135+
// not a container;
136+
}
137+
return false;
138+
}
139+
140+
protected function checkContainerCType(int $containerId, string $cType, int $colPos): bool
141+
{
142+
try {
143+
$container = $this->containerFactory->buildContainer($containerId);
144+
$columnConfiguration = $this->registry->getContentDefenderConfiguration($container->getCType(), $colPos);
145+
$allowedContentElementsInTargetColPos = GeneralUtility::trimExplode(',', $columnConfiguration['allowedContentTypes'] ?? '', true);
146+
$disallowedContentElementsInTargetColPos = GeneralUtility::trimExplode(',', $columnConfiguration['disallowedContentTypes'] ?? '', true);
147+
if ((!empty($allowedContentElementsInTargetColPos) && !in_array($cType, $allowedContentElementsInTargetColPos, true))
148+
|| (!empty($disallowedContentElementsInTargetColPos) && in_array($cType, $disallowedContentElementsInTargetColPos, true))
149+
) {
150+
return false;
151+
}
152+
} catch (Exception) {
153+
// not a container;
154+
}
155+
return true;
156+
}
157+
158+
public function processCmdmap_postProcess(string $command, string $table, $id, $value, DataHandler $dataHandler, $pasteUpdate, $pasteDatamap): void
159+
{
160+
$this->lockDatamapHook = false;
161+
}
162+
163+
public function processDatamap_beforeStart(DataHandler $dataHandler): void
164+
{
165+
if ($this->lockDatamapHook === true) {
166+
return;
167+
}
168+
$datamap = $dataHandler->datamap;
169+
if (empty($datamap['tt_content']) || $dataHandler->bypassAccessCheckForRecords) {
170+
return;
171+
}
172+
foreach ($datamap['tt_content'] as $id => $incomingFieldArray) {
173+
if (MathUtility::canBeInterpretedAsInteger($id)) {
174+
$record = BackendUtility::getRecord('tt_content', $id);
175+
if (!is_array($record)) {
176+
// Skip this if the record could not be determined for whatever reason
177+
continue;
178+
}
179+
$recordData = array_merge($record, $incomingFieldArray);
180+
} else {
181+
$recordData = array_merge($dataHandler->defaultValues['tt_content'] ?? [], $incomingFieldArray);
182+
}
183+
// EXT:container start
184+
if ((int)($recordData['tx_container_parent'] ?? 0) > 0 && (int)($recordData['colPos'] ?? 0) > 0) {
185+
if ($this->checkContainerMaxItems((int)$recordData['tx_container_parent'], (int)$recordData['colPos'])) {
186+
if (MathUtility::canBeInterpretedAsInteger($id)) {
187+
// edit
188+
continue;
189+
}
190+
unset($dataHandler->datamap['tt_content'][$id]);
191+
$dataHandler->log('tt_content', $id, 1, null, 1, 'The command "%s" for record "tt_content:%s" to colPos "%s" couldn\'t be executed due to maxitems reached.', null, [$id, $recordData['colPos']]);
192+
}
193+
}
194+
// EXT:container end
195+
if (empty($recordData['CType']) || !array_key_exists('colPos', $recordData)) {
196+
// No idea what happened here, but we stop with this record if there is no CType or colPos
197+
continue;
198+
}
199+
$pageId = (int)$recordData['pid'];
200+
if ($pageId < 0) {
201+
$previousRecord = BackendUtility::getRecord('tt_content', abs($pageId), 'pid');
202+
if ($previousRecord === null) {
203+
// Broken target data. Stop here and let DH handle this mess.
204+
continue;
205+
}
206+
$pageId = (int)$previousRecord['pid'];
207+
}
208+
$colPos = (int)$recordData['colPos'];
209+
$backendLayout = $this->backendLayoutView->getBackendLayoutForPage($pageId);
210+
$columnConfiguration = $this->backendLayoutView->getColPosConfigurationForPage($backendLayout, $colPos, $pageId);
211+
$allowedContentElementsInTargetColPos = GeneralUtility::trimExplode(',', $columnConfiguration['allowedContentTypes'] ?? '', true);
212+
$disallowedContentElementsInTargetColPos = GeneralUtility::trimExplode(',', $columnConfiguration['disallowedContentTypes'] ?? '', true);
213+
if ((!empty($allowedContentElementsInTargetColPos) && !in_array($recordData['CType'], $allowedContentElementsInTargetColPos, true))
214+
|| (!empty($disallowedContentElementsInTargetColPos) && in_array($recordData['CType'], $disallowedContentElementsInTargetColPos, true))
215+
) {
216+
// Not allowed to create in this colPos on this page. Unset this command and create a log entry which may be turned into a notification when called by BE.
217+
unset($dataHandler->datamap['tt_content'][$id]);
218+
$dataHandler->log('tt_content', $id, 1, null, 1, 'The record "tt_content:%s" with CType "%s" in colPos "%s" couldn\'t be saved due to disallowed value(s).', null, [$id, $recordData['CType'], $colPos]);
219+
}
220+
}
221+
}
222+
}

Classes/Hooks/Datahandler/DatahandlerProcess.php

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,25 @@ class DatahandlerProcess implements SingletonInterface
1818
{
1919
protected $containerInProcess = [];
2020

21+
protected $contentElementRestrictionsLook = false;
22+
23+
protected $stack = 0;
24+
25+
public function areContentElementRestrictionsLooked(): bool
26+
{
27+
return $this->stack > 0;
28+
}
29+
30+
public function unlockContentElementRestrictions(): void
31+
{
32+
$this->stack--;
33+
}
34+
35+
public function lockContentElementRestrictions(): void
36+
{
37+
$this->stack++;
38+
}
39+
2140
public function isContainerInProcess(int $containerId): bool
2241
{
2342
return in_array($containerId, $this->containerInProcess, true);
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace B13\Container\Listener;
6+
7+
/*
8+
* This file is part of TYPO3 CMS-based extension "container" by b13.
9+
*
10+
* It is free software; you can redistribute it and/or modify it under
11+
* the terms of the GNU General Public License, either version 2
12+
* of the License, or any later version.
13+
*/
14+
15+
use B13\Container\Domain\Factory\ContainerFactory;
16+
use B13\Container\Domain\Factory\Exception;
17+
use B13\Container\Tca\Registry;
18+
use Psr\Http\Message\ServerRequestInterface;
19+
use TYPO3\CMS\Backend\Utility\BackendUtility;
20+
use TYPO3\CMS\Backend\View\Event\ManipulateBackendLayoutColPosConfigurationForPageEvent;
21+
22+
class ManipulateBackendLayoutColPosConfigurationForPage
23+
{
24+
/**
25+
* @var Registry
26+
*/
27+
protected $tcaRegistry;
28+
29+
/**
30+
* @var ContainerFactory
31+
*/
32+
protected $containerFactory;
33+
34+
public function __construct(ContainerFactory $containerFactory, Registry $tcaRegistry)
35+
{
36+
$this->containerFactory = $containerFactory;
37+
$this->tcaRegistry = $tcaRegistry;
38+
}
39+
40+
public function __invoke(ManipulateBackendLayoutColPosConfigurationForPageEvent $e)
41+
{
42+
$parent = $this->getParentUid($e->request);
43+
if ($parent === null) {
44+
return;
45+
}
46+
47+
try {
48+
$container = $this->containerFactory->buildContainer($parent);
49+
} catch (Exception $e) {
50+
// not a container
51+
return;
52+
}
53+
$cType = $container->getCType();
54+
$configuration = $this->tcaRegistry->getContentDefenderConfiguration($cType, $e->colPos);
55+
$e->configuration = [
56+
'allowedContentTypes' => $configuration['allowedContentTypes'],
57+
'disallowedContentTypes' => $configuration['disallowedContentTypes'],
58+
];
59+
}
60+
61+
private function getParentUid(?ServerRequestInterface $request): ?int
62+
{
63+
if ($request === null) {
64+
return null;
65+
}
66+
$queryParams = $request->getQueryParams();
67+
if (isset($queryParams['tx_container_parent']) && $queryParams['tx_container_parent'] > 0) {
68+
// new content elemment wizard
69+
return (int)$queryParams['tx_container_parent'];
70+
}
71+
if (
72+
isset($queryParams['defVals']['tt_content']['tx_container_parent']) &&
73+
$queryParams['defVals']['tt_content']['tx_container_parent'] > 0
74+
) {
75+
// TcaCTypeItems: new record
76+
return (int)$queryParams['defVals']['tt_content']['tx_container_parent'];
77+
}
78+
if (isset($queryParams['edit']['tt_content'])) {
79+
$recordUid = array_keys($queryParams['edit']['tt_content'])[0];
80+
$recordUid = (int)abs($recordUid);
81+
// TcaCTypeItems: edit record
82+
$record = BackendUtility::getRecord('tt_content', $recordUid, 'tx_container_parent');
83+
if (isset($record['tx_container_parent'])) {
84+
return (int)$record['tx_container_parent'];
85+
}
86+
}
87+
return null;
88+
}
89+
}

0 commit comments

Comments
 (0)