Skip to content

Commit 2c35d93

Browse files
authored
[TASK] Split save persistence from edit mode middleware (#102)
Handle visual editor persistence through a backend AJAX controller and save button coordinator. This keeps the middleware responsible for edit-mode setup while the backend UI owns save execution and completion handling.
1 parent 22bb38d commit 2c35d93

17 files changed

Lines changed: 198 additions & 194 deletions
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace TYPO3\CMS\VisualEditor\Backend\Controller;
6+
7+
use Psr\Http\Message\ResponseInterface;
8+
use Psr\Http\Message\ServerRequestInterface;
9+
use RuntimeException;
10+
use TYPO3\CMS\Backend\Attribute\AsController;
11+
use TYPO3\CMS\Core\Http\JsonResponse;
12+
use TYPO3\CMS\VisualEditor\Service\DataHandlerService;
13+
14+
use function array_keys;
15+
use function implode;
16+
use function is_array;
17+
18+
#[AsController]
19+
final readonly class PersistenceController
20+
{
21+
public function __construct(
22+
private DataHandlerService $dataHandlerService,
23+
) {
24+
}
25+
26+
public function saveAction(ServerRequestInterface $request): ResponseInterface
27+
{
28+
$input = $this->getJsonPayload($request);
29+
30+
$data = $input['data'] ?? [];
31+
unset($input['data']);
32+
$cmdArray = $input['cmdArray'] ?? [];
33+
unset($input['cmdArray']);
34+
if (!is_array($data)) {
35+
throw new RuntimeException('Data must be an array of table names to rows', 5781185589);
36+
}
37+
38+
if (!is_array($cmdArray)) {
39+
throw new RuntimeException('Command array must be a list of DataHandler commands', 4576273831);
40+
}
41+
42+
if ($input !== []) {
43+
throw new RuntimeException('Unknown data operations: ' . implode(', ', array_keys($input)) . ' only data and cmdArray are allowed', 8110225095);
44+
}
45+
46+
$GLOBALS['TYPO3_REQUEST'] = $request;
47+
$errorLog = $this->dataHandlerService->run($data, []);
48+
49+
foreach ($cmdArray as $cmd) {
50+
$errorLog = [...$errorLog, ...$this->dataHandlerService->run([], $cmd)];
51+
}
52+
53+
if ($errorLog) {
54+
return new JsonResponse(['success' => false, 'errorLog' => $errorLog], 500);
55+
}
56+
57+
return new JsonResponse(['success' => true]);
58+
}
59+
60+
/**
61+
* @return array<string, mixed>
62+
*/
63+
private function getJsonPayload(ServerRequestInterface $request): array
64+
{
65+
$payload = $request->getParsedBody();
66+
if (!is_array($payload)) {
67+
$payload = json_decode((string)$request->getBody(), true, 512, JSON_THROW_ON_ERROR);
68+
}
69+
70+
if (!is_array($payload)) {
71+
throw new RuntimeException('Save payload must be a JSON object', 2634277014);
72+
}
73+
74+
return $payload;
75+
}
76+
}

Classes/Middleware/PersistenceMiddleware.php renamed to Classes/Middleware/EditModeMiddleware.php

Lines changed: 7 additions & 88 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@
88
use Psr\Http\Message\ServerRequestInterface;
99
use Psr\Http\Server\MiddlewareInterface;
1010
use Psr\Http\Server\RequestHandlerInterface;
11-
use RuntimeException;
1211
use TYPO3\CMS\Backend\Middleware\JavaScriptLabelImportMapEntryResolver;
1312
use TYPO3\CMS\Backend\Routing\Router;
1413
use TYPO3\CMS\Backend\Routing\UriBuilder;
@@ -17,33 +16,23 @@
1716
use TYPO3\CMS\Core\Context\VisibilityAspect;
1817
use TYPO3\CMS\Core\Error\Http\UnauthorizedException;
1918
use TYPO3\CMS\Core\EventDispatcher\ListenerProvider;
20-
use TYPO3\CMS\Core\FormProtection\FormProtectionFactory;
2119
use TYPO3\CMS\Core\Http\HtmlResponse;
2220
use TYPO3\CMS\Core\Http\ImmediateResponseException;
23-
use TYPO3\CMS\Core\Http\JsonResponse;
2421
use TYPO3\CMS\Core\Information\Typo3Version;
2522
use TYPO3\CMS\Core\Page\Event\ResolveVirtualJavaScriptImportEvent;
2623
use TYPO3\CMS\Core\Page\PageRenderer;
27-
use TYPO3\CMS\Core\Type\Bitmask\Permission;
2824
use TYPO3\CMS\Core\Utility\GeneralUtility;
2925
use TYPO3\CMS\Core\View\ViewFactoryData;
3026
use TYPO3\CMS\Core\View\ViewFactoryInterface;
31-
use TYPO3\CMS\Frontend\Page\PageInformation;
32-
use TYPO3\CMS\VisualEditor\Service\DataHandlerService;
3327

34-
use function array_keys;
35-
use function implode;
36-
use function json_decode;
3728
use function substr;
3829

39-
readonly class PersistenceMiddleware implements MiddlewareInterface
30+
readonly class EditModeMiddleware implements MiddlewareInterface
4031
{
4132
public function __construct(
4233
private Context $context,
43-
private DataHandlerService $dataHandlerService,
4434
private UriBuilder $uriBuilder,
4535
private ViewFactoryInterface $viewFactory,
46-
private FormProtectionFactory $formProtectionFactory,
4736
private Typo3Version $typo3Version,
4837
private ListenerProvider $listenerProvider,
4938
private PageRenderer $pageRenderer,
@@ -52,54 +41,19 @@ public function __construct(
5241

5342
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
5443
{
55-
return match ($this->whatToDo($request)) {
56-
MiddlewareAction::Edit => $this->handleEdit($request, $handler),
57-
MiddlewareAction::Save => $this->saveStuff($request),
58-
MiddlewareAction::None => $handler->handle($request),
59-
};
60-
}
61-
62-
private function saveStuff(ServerRequestInterface $request): ResponseInterface
63-
{
64-
$token = $request->getHeaderLine('X-Request-Token');
65-
if (!$token || !$this->formProtectionFactory->createForType('backend')->validateToken($token, 'visual_editor', 'save')) {
66-
throw new UnauthorizedException('Invalid or missing request token', 8148623595);
67-
}
68-
69-
$input = $request->getParsedBody() ??
70-
json_decode($request->getBody()->getContents(), true, 512, JSON_THROW_ON_ERROR);
71-
72-
$data = $input['data'] ?? [];
73-
unset($input['data']);
74-
$cmdArray = $input['cmdArray'] ?? [];
75-
unset($input['cmdArray']);
76-
77-
if (!empty($input)) {
78-
throw new RuntimeException('Unknown data operations: ' . implode(', ', array_keys($input)) . ' only data and cmdArray are allowed', 8110225095);
44+
if ($this->shouldInitEditMode($request)) {
45+
return $this->handleEdit($request, $handler);
7946
}
8047

81-
// Required by DefaultSanitizerBuilder when processing RTE fields via DataHandler;
82-
// this middleware short-circuits before the FE RequestHandler which normally sets this global.
83-
$GLOBALS['TYPO3_REQUEST'] = $request;
84-
$errorLog = $this->dataHandlerService->run($data, []);
85-
86-
foreach ($cmdArray as $cmd) {
87-
$errorLog = [...$errorLog, ...$this->dataHandlerService->run([], $cmd)];
88-
}
89-
90-
if ($errorLog) {
91-
return new JsonResponse(['success' => false, 'errorLog' => $errorLog], 500);
92-
}
93-
94-
return new JsonResponse(['success' => true]);
48+
return $handler->handle($request);
9549
}
9650

97-
private function whatToDo(ServerRequestInterface $request): MiddlewareAction
51+
private function shouldInitEditMode(ServerRequestInterface $request): bool
9852
{
9953
// parameter editMode must be set
10054
$params = $request->getQueryParams();
10155
if (!isset($params['editMode'])) {
102-
return MiddlewareAction::None;
56+
return false;
10357
}
10458

10559
// backend user required
@@ -123,42 +77,7 @@ private function whatToDo(ServerRequestInterface $request): MiddlewareAction
12377
throw new UnauthorizedException('No $GLOBALS[\'BE_USER\'] available', 8725323237);
12478
}
12579

126-
// only do something on POST requests
127-
if ($request->getMethod() !== 'POST') {
128-
return MiddlewareAction::Edit;
129-
}
130-
131-
// only allow application/json content type
132-
if ($request->getHeaderLine('Content-Type') !== 'application/json') {
133-
throw new UnauthorizedException('Content-Type must be application/json to save stuff with visual_editor', 5015404100);
134-
}
135-
136-
if ($user->isAdmin()) {
137-
return MiddlewareAction::Save;
138-
}
139-
140-
// check permissions of user on page
141-
$pageInformation = $this->getPageInformation($request);
142-
143-
if (!$beUser->isInWebMount($pageInformation->getId())) {
144-
throw new UnauthorizedException('No permission to access this page', 1610177162);
145-
}
146-
147-
if (!$beUser->doesUserHaveAccess($pageInformation->getPageRecord(), Permission::CONTENT_EDIT)) {
148-
throw new UnauthorizedException('No permission to edit content on this page', 7668402611);
149-
}
150-
151-
return MiddlewareAction::Save;
152-
}
153-
154-
private function getPageInformation(ServerRequestInterface $request): PageInformation
155-
{
156-
$frontendPageInformation = $request->getAttribute('frontend.page.information');
157-
if (!$frontendPageInformation instanceof PageInformation) {
158-
throw new RuntimeException('No frontend page information available', 7005099635);
159-
}
160-
161-
return $frontendPageInformation;
80+
return true;
16281
}
16382

16483
private function handleEdit(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface

Classes/Middleware/MiddlewareAction.php

Lines changed: 0 additions & 12 deletions
This file was deleted.

Configuration/Backend/AjaxRoutes.php

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
declare(strict_types=1);
44

55
use TYPO3\CMS\VisualEditor\Backend\Controller\CrossOriginNavigationController;
6+
use TYPO3\CMS\VisualEditor\Backend\Controller\PersistenceController;
67

78
return [
89
'visual_editor_resolve_cross_origin_backend_url' => [
@@ -11,4 +12,10 @@
1112
'methods' => ['POST'],
1213
'inheritAccessFromModule' => 'web_edit',
1314
],
15+
'visual_editor_save' => [
16+
'path' => '/visual-editor/save',
17+
'target' => PersistenceController::class . '::saveAction',
18+
'methods' => ['POST'],
19+
'inheritAccessFromModule' => 'web_edit',
20+
],
1421
];

Configuration/RequestMiddlewares.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,12 @@
33
declare(strict_types=1);
44

55
use TYPO3\CMS\VisualEditor\Middleware\DisableCacheInEditModeMiddleware;
6-
use TYPO3\CMS\VisualEditor\Middleware\PersistenceMiddleware;
6+
use TYPO3\CMS\VisualEditor\Middleware\EditModeMiddleware;
77

88
return [
99
'frontend' => [
1010
'typo3/cms-visual-editor/persistence-middleware' => [
11-
'target' => PersistenceMiddleware::class,
11+
'target' => EditModeMiddleware::class,
1212
'after' => [
1313
'typo3/cms-frontend/prepare-tsfe-rendering',
1414
'typo3/cms-frontend/tsfe', // TODO typo3/cms-frontend/tsfe can be dropped if TYPO3 14 is lowest supported version

Resources/Public/JavaScript/Backend/components/ve-auto-save-toggle.js

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import {css, html, LitElement} from 'lit';
22
import {onMessageDebounced, sendMessage} from '@typo3/visual-editor/Shared/iframe-messaging';
33
import {autoSaveActive} from '@typo3/visual-editor/Shared/local-stores';
4+
import {VeBackendSaveButton} from '@typo3/visual-editor/Backend/components/ve-backend-save-button';
45

56

67
/**
@@ -76,7 +77,7 @@ export class VeAutoSaveToggle extends LitElement {
7677
this.count = count;
7778
this.invalidCount = invalidCount;
7879
if (this.active && this.count > 0 && this.invalidCount === 0) {
79-
sendMessage('doSave');
80+
this.triggerDoSave();
8081
}
8182
}
8283

@@ -91,9 +92,18 @@ export class VeAutoSaveToggle extends LitElement {
9192
autoSaveActive.set(this.active);
9293

9394
if (this.active && this.count > 0 && this.invalidCount === 0) {
94-
sendMessage('doSave');
95+
this.triggerDoSave();
9596
}
9697
}
98+
99+
triggerDoSave() {
100+
const element = document.querySelector('ve-backend-save-button');
101+
if (!(element instanceof VeBackendSaveButton)) {
102+
throw new Error('ve-backend-save-button is missing, could not autosave');
103+
}
104+
element.doSave();
105+
}
106+
97107
#onKeydown(e) {
98108
if (this.hasAttribute('disabled') || (e.key !== 'Enter' && e.key !== ' ')) {
99109
return;

0 commit comments

Comments
 (0)