TYPO3 v15 dev-main snapshot ()

This commit is contained in:
2026-08-10 22:31:00 +02:00
commit f9941541b7
1178 changed files with 135377 additions and 0 deletions
+95
View File
@@ -0,0 +1,95 @@
<?php
declare(strict_types=1);
/*
* 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 TYPO3\CMS\Backend\Controller;
use Psr\EventDispatcher\EventDispatcherInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Backend\Attribute\AsController;
use TYPO3\CMS\Backend\Module\ModuleProvider;
use TYPO3\CMS\Backend\Template\Enum\ModuleLayout;
use TYPO3\CMS\Backend\Template\ModuleTemplateFactory;
use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
use TYPO3\CMS\Core\Information\Typo3Information;
use TYPO3\CMS\Core\Information\Typo3Version;
use TYPO3\CMS\Core\Package\PackageManager;
/**
* Module 'about' shows some standard information for TYPO3 CMS:
* About-text, version number, available modules and so on.
*
* @internal This is a specific Backend Controller implementation and is not considered part of the Public TYPO3 API.
*/
#[AsController]
readonly class AboutController
{
public function __construct(
protected Typo3Version $version,
protected Typo3Information $typo3Information,
protected ModuleProvider $moduleProvider,
protected EventDispatcherInterface $eventDispatcher,
protected PackageManager $packageManager,
protected ModuleTemplateFactory $moduleTemplateFactory,
) {}
/**
* Main action: Show standard information
*/
public function handleRequest(ServerRequestInterface $request): ResponseInterface
{
$event = new Event\ModifyGenericBackendMessagesEvent();
$event = $this->eventDispatcher->dispatch($event);
$view = $this->moduleTemplateFactory->create($request);
$view->setLayout(ModuleLayout::NORMAL);
$view->assignMultiple([
'typo3Info' => $this->typo3Information,
'typo3Version' => $this->version,
'donationUrl' => $this->typo3Information::URL_DONATE,
'trademarkUrl' => $this->typo3Information::URL_TRADEMARK,
'loadedExtensions' => $this->getLoadedExtensions(),
'messages' => $event->getMessages(),
'modules' => $this->moduleProvider->getModules($this->getBackendUser()),
]);
return $view->renderResponse('About/Index');
}
/**
* Fetches a list of all active (loaded) extensions in the current system
*/
protected function getLoadedExtensions(): array
{
$extensions = [];
foreach ($this->packageManager->getActivePackages() as $package) {
// Skip system extensions
if ($package->getPackageMetaData()->isFrameworkType()) {
continue;
}
$extensions[] = [
'key' => $package->getPackageKey(),
'title' => $package->getPackageMetaData()->getTitle(),
'authors' => $package->getValueFromComposerManifest('authors'),
];
}
return $extensions;
}
protected function getBackendUser(): BackendUserAuthentication
{
return $GLOBALS['BE_USER'];
}
}
@@ -0,0 +1,80 @@
<?php
declare(strict_types=1);
/*
* 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 TYPO3\CMS\Backend\Controller;
use TYPO3\CMS\Core\Localization\LanguageService;
use TYPO3\CMS\Core\Localization\LanguageServiceFactory;
use TYPO3\CMS\Core\Page\JavaScriptItems;
use TYPO3\CMS\Core\Page\JavaScriptModuleInstruction;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Core\Utility\PathUtility;
/**
* Abstract class for a couple of FormEngine controllers triggered by
* ajax calls. The class containers some helpers to for instance prepare
* the form render result for json output.
*
* @internal Marked as internal for now, methods in this class may change any time.
*/
abstract readonly class AbstractFormEngineAjaxController
{
protected function addJavaScriptModulesToJavaScriptItems(array $modules, JavaScriptItems $items): void
{
foreach ($modules as $module) {
if (!$module instanceof JavaScriptModuleInstruction) {
throw new \LogicException(
sprintf(
'Module must be a %s, type "%s" given',
JavaScriptModuleInstruction::class,
gettype($module)
),
1663851377
);
}
$items->addJavaScriptModuleInstruction($module);
}
}
/**
* Resolve a CSS file position, possibly prefixed with 'EXT:'
*
* @param string $stylesheetFile Given file, possibly prefixed with EXT:
* @return string URL to file
*/
protected function getRelativePathToStylesheetFile(string $stylesheetFile): string
{
return (string)PathUtility::getSystemResourceUri($stylesheetFile);
}
/**
* Parse a language file and get a label/value array from it.
*
* @param string $file EXT:path/to/file
* @return array Label/value array
*/
protected function getLabelsFromLocalizationFile(string $file): array
{
$languageService = $this->getLanguageService() ?? GeneralUtility::makeInstance(LanguageServiceFactory::class)->create('en');
return $languageService->getLabelsFromResource($file);
}
protected function getLanguageService(): ?LanguageService
{
return $GLOBALS['LANG'] ?? null;
}
}
@@ -0,0 +1,556 @@
<?php
declare(strict_types=1);
/*
* 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 TYPO3\CMS\Backend\Controller;
use Psr\EventDispatcher\EventDispatcherInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Backend\Controller\Event\ModifyAllowedItemsEvent;
use TYPO3\CMS\Backend\Controller\Event\ModifyLinkHandlersEvent;
use TYPO3\CMS\Backend\LinkHandler\LinkHandlerInterface;
use TYPO3\CMS\Backend\LinkHandler\LinkHandlerVariableProviderInterface;
use TYPO3\CMS\Backend\LinkHandler\LinkHandlerViewProviderInterface;
use TYPO3\CMS\Backend\Routing\UriBuilder;
use TYPO3\CMS\Backend\Template\PageRendererBackendSetupTrait;
use TYPO3\CMS\Backend\Utility\BackendUtility;
use TYPO3\CMS\Backend\View\BackendViewFactory;
use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
use TYPO3\CMS\Core\Configuration\ExtensionConfiguration;
use TYPO3\CMS\Core\Http\HtmlResponse;
use TYPO3\CMS\Core\Localization\LanguageService;
use TYPO3\CMS\Core\Page\PageRenderer;
use TYPO3\CMS\Core\Service\DependencyOrderingService;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Core\View\ViewInterface;
/**
* Script class for the Link Browser window.
*
* @internal This class is a specific Backend controller implementation and is not part of the TYPO3's Core API.
*/
abstract class AbstractLinkBrowserController
{
use PageRendererBackendSetupTrait;
/**
* @var array<string, array>
*/
protected array $linkHandlers = [];
/**
* All parts of the current link.
* Comprised of url information and additional link parameters.
*
* @var array<string, mixed>
*/
protected array $currentLinkParts = [];
/**
* Link handler responsible for the current active link
*/
protected ?LinkHandlerInterface $currentLinkHandler = null;
/**
* The ID of the currently active link handler
*/
protected string $currentLinkHandlerId;
/**
* Link handler to be displayed
*/
protected ?LinkHandlerInterface $displayedLinkHandler = null;
/**
* The ID of the displayed link handler
* This is read from the 'act' GET parameter
*/
protected string $displayedLinkHandlerId = '';
/**
* List of available link attribute fields
*
* @var string[]
*/
protected array $linkAttributeFields = [];
/**
* Values of the link attributes
*
* @var string[]
*/
protected array $linkAttributeValues = [];
protected array $parameters;
protected DependencyOrderingService $dependencyOrderingService;
protected PageRenderer $pageRenderer;
protected UriBuilder $uriBuilder;
protected ExtensionConfiguration $extensionConfiguration;
protected BackendViewFactory $backendViewFactory;
protected EventDispatcherInterface $eventDispatcher;
public function injectDependencyOrderingService(DependencyOrderingService $dependencyOrderingService): void
{
$this->dependencyOrderingService = $dependencyOrderingService;
}
public function injectPageRenderer(PageRenderer $pageRenderer): void
{
$this->pageRenderer = $pageRenderer;
}
public function injectUriBuilder(UriBuilder $uriBuilder): void
{
$this->uriBuilder = $uriBuilder;
}
public function injectExtensionConfiguration(ExtensionConfiguration $extensionConfiguration): void
{
$this->extensionConfiguration = $extensionConfiguration;
}
public function injectBackendViewFactory(BackendViewFactory $backendViewFactory): void
{
$this->backendViewFactory = $backendViewFactory;
}
public function injectEventDispatcher(EventDispatcherInterface $eventDispatcher): void
{
$this->eventDispatcher = $eventDispatcher;
}
abstract public function getConfiguration(): array;
abstract protected function initDocumentTemplate(): void;
abstract protected function getCurrentPageId(): int;
/**
* Injects the request object for the current request or subrequest
* As this controller goes only through the main() method, it is rather simple for now
*
* @param ServerRequestInterface $request the current request
* @return ResponseInterface the response with the content
*/
public function mainAction(ServerRequestInterface $request): ResponseInterface
{
$this->setUpBasicPageRendererForBackend($this->pageRenderer, $this->extensionConfiguration, $request, $this->getLanguageService());
$this->pageRenderer->addInlineLanguageLabelFile('EXT:core/Resources/Private/Language/locallang_misc.xlf');
$this->pageRenderer->addInlineLanguageLabelFile('EXT:core/Resources/Private/Language/locallang_core.xlf');
$this->initVariables($request);
$this->loadLinkHandlers();
$this->initCurrentUrl();
$menuData = $this->buildMenuArray($request);
if ($this->displayedLinkHandler instanceof LinkHandlerViewProviderInterface) {
$view = $this->displayedLinkHandler->createView($this->backendViewFactory, $request);
} else {
$view = $this->backendViewFactory->create($request, ['typo3/cms-backend']);
}
if ($this->displayedLinkHandler instanceof LinkHandlerVariableProviderInterface) {
$this->displayedLinkHandler->initializeVariables($request);
}
$renderLinkAttributeFields = $this->renderLinkAttributeFields($view);
if (!empty($this->currentLinkParts)) {
$this->renderCurrentUrl($view);
}
if (method_exists($this->displayedLinkHandler, 'setView')) {
$this->displayedLinkHandler->setView($view);
}
$view->assignMultiple([
'initialNavigationWidth' => $this->getBackendUser()->uc['selector']['navigation']['width'] ?? 250,
'menuItems' => $menuData,
'linkAttributes' => $renderLinkAttributeFields,
'contentOnly' => $request->getQueryParams()['contentOnly'] ?? false,
]);
$content = $this->displayedLinkHandler->render($request);
if (empty($content)) {
// @todo: b/w compat layer for link handler that don't render full view but return empty
// string instead. This case is unfortunate and should be removed if it gives
// headaches at some point. If so, above method_exists($this->displayedLinkHandler, 'setView')
// should be removed and setView() method should be made mandatory, or the entire
// construct should be refactored a bit.
$content = $view->render();
}
$this->initDocumentTemplate();
$this->pageRenderer->setTitle($this->getLanguageService()->sL(
'LLL:EXT:backend/Resources/Private/Language/locallang_browse_links.xlf:linkBrowser'
));
if ($request->getQueryParams()['contentOnly'] ?? false) {
return new HtmlResponse($content);
}
$this->pageRenderer->setBodyContent('<body ' . GeneralUtility::implodeAttributes($this->getBodyTagAttributes(), true, true) . '>' . $content);
return $this->pageRenderer->renderResponse($request);
}
/**
* @return array{act: string, P: array} Array of parameters which have to be added to URLs
*/
public function getUrlParameters(?array $overrides = null): array
{
return [
'act' => $overrides['act'] ?? $this->displayedLinkHandlerId,
'P' => $overrides['P'] ?? $this->parameters,
];
}
public function getParameters(): array
{
return $this->parameters;
}
protected function initVariables(ServerRequestInterface $request): void
{
$queryParams = $request->getQueryParams();
$this->displayedLinkHandlerId = $queryParams['act'] ?? '';
$this->parameters = $queryParams['P'] ?? [];
$this->linkAttributeValues = $queryParams['linkAttributes'] ?? [];
$pageTsConfig = BackendUtility::getPagesTSconfig((int)($this->parameters['pid'] ?? 0));
$handlerId = $this->displayedLinkHandlerId ?: 'page';
if (empty($this->linkAttributeValues['target'])) {
$defaultTarget = $pageTsConfig['TCEMAIN.']['linkHandler.'][$handlerId . '.']['target.']['default']
?? $pageTsConfig['TCEMAIN.']['linkHandler.']['properties.']['target.']['default']
?? '';
if (!empty($defaultTarget)) {
$this->linkAttributeValues['target'] = $defaultTarget;
}
}
if (empty($this->linkAttributeValues['class'])) {
$defaultCssClass = $pageTsConfig['TCEMAIN.']['linkHandler.'][$handlerId . '.']['cssClass.']['default']
?? $pageTsConfig['TCEMAIN.']['linkHandler.']['properties.']['cssClass.']['default']
?? '';
if (!empty($defaultCssClass)) {
$this->linkAttributeValues['class'] = $defaultCssClass;
}
}
}
/**
* @throws \UnexpectedValueException
*/
protected function loadLinkHandlers(): void
{
$linkHandlers = $this->getLinkHandlers();
if (empty($linkHandlers)) {
throw new \UnexpectedValueException('No link handlers are configured. Check page TSconfig TCEMAIN.linkHandler.', 1442787911);
}
$lang = $this->getLanguageService();
foreach ($linkHandlers as $identifier => $configuration) {
$identifier = rtrim($identifier, '.');
if ($identifier === 'properties') {
continue;
}
if (empty($configuration['handler'])) {
throw new \UnexpectedValueException(sprintf('Missing handler for link handler "%1$s", check page TSconfig TCEMAIN.linkHandler.%1$s.handler', $identifier), 1494579849);
}
/** @var LinkHandlerInterface $handler */
$handler = GeneralUtility::makeInstance($configuration['handler']);
$handler->initialize(
$this,
$identifier,
$configuration['configuration.'] ?? []
);
$label = !empty($configuration['label']) ? $lang->sL($configuration['label']) : '';
$label = $label ?: $lang->sL('LLL:EXT:backend/Resources/Private/Language/locallang.xlf:error.linkHandlerTitleMissing');
$this->linkHandlers[$identifier] = [
'handlerInstance' => $handler,
'label' => $label,
'displayBefore' => isset($configuration['displayBefore']) ? GeneralUtility::trimExplode(',', $configuration['displayBefore']) : [],
'displayAfter' => isset($configuration['displayAfter']) ? GeneralUtility::trimExplode(',', $configuration['displayAfter']) : [],
'scanBefore' => isset($configuration['scanBefore']) ? GeneralUtility::trimExplode(',', $configuration['scanBefore']) : [],
'scanAfter' => isset($configuration['scanAfter']) ? GeneralUtility::trimExplode(',', $configuration['scanAfter']) : [],
'addParams' => $configuration['addParams'] ?? '',
];
}
}
/**
* Reads the configured link handlers from page TSconfig
*
* @return array<string, array>
*/
protected function getLinkHandlers(): array
{
$linkHandlers = (array)(BackendUtility::getPagesTSconfig($this->getCurrentPageId())['TCEMAIN.']['linkHandler.'] ?? []);
return $this->eventDispatcher
->dispatch(new ModifyLinkHandlersEvent($linkHandlers, $this->currentLinkParts))
->getLinkHandlers();
}
/**
* Initialize $this->currentLinkParts and $this->currentLinkHandler
*/
protected function initCurrentUrl(): void
{
if (empty($this->currentLinkParts)) {
return;
}
$orderedHandlers = $this->dependencyOrderingService->orderByDependencies($this->linkHandlers, 'scanBefore', 'scanAfter');
// find responsible handler for current link
foreach ($orderedHandlers as $key => $configuration) {
/** @var LinkHandlerInterface $handler */
$handler = $configuration['handlerInstance'];
if ($handler->canHandleLink($this->currentLinkParts)) {
$this->currentLinkHandler = $handler;
$this->currentLinkHandlerId = $key;
break;
}
}
// reset the link if we have no handler for it
if (!$this->currentLinkHandler) {
$this->currentLinkParts = [];
}
// overwrite any preexisting
foreach ($this->currentLinkParts as $key => $part) {
if ($key !== 'url') {
$this->linkAttributeValues[$key] = $part;
}
}
}
/**
* Add the currently set Link URL to the view
*/
protected function renderCurrentUrl(ViewInterface $view): void
{
$view->assign('currentLink', $this->currentLinkHandler->formatCurrentUrl());
}
/**
* Returns an array definition of the top menu
*
* @return array[]
*/
protected function buildMenuArray(ServerRequestInterface $request): array
{
$allowedItems = $this->getAllowedItems();
if ($this->displayedLinkHandlerId && !in_array($this->displayedLinkHandlerId, $allowedItems, true)) {
$this->displayedLinkHandlerId = '';
}
$allowedHandlers = array_flip($allowedItems);
$menuDef = [];
foreach ($this->linkHandlers as $identifier => $configuration) {
if (!isset($allowedHandlers[$identifier])) {
continue;
}
/** @var LinkHandlerInterface $handlerInstance */
$handlerInstance = $configuration['handlerInstance'];
$isActive = $this->displayedLinkHandlerId === $identifier || (!$this->displayedLinkHandlerId && $handlerInstance === $this->currentLinkHandler);
if ($isActive) {
$this->displayedLinkHandler = $handlerInstance;
if (!$this->displayedLinkHandlerId) {
$this->displayedLinkHandlerId = $this->currentLinkHandlerId;
}
}
$menuDef[$identifier] = [
'isActive' => $isActive,
'label' => $configuration['label'],
'url' => $this->uriBuilder->buildUriFromRequest($request, $this->getUrlParameters(['act' => $identifier])),
'addParams' => $configuration['addParams'] ?? '',
'before' => $configuration['displayBefore'],
'after' => $configuration['displayAfter'],
];
}
$menuDef = $this->dependencyOrderingService->orderByDependencies($menuDef);
// if there is no active tab
if (!$this->displayedLinkHandler) {
// empty the current link
$this->currentLinkParts = [];
$this->currentLinkHandler = null;
// select first tab
$this->displayedLinkHandlerId = (string)array_key_first($menuDef);
$this->displayedLinkHandler = $this->linkHandlers[$this->displayedLinkHandlerId]['handlerInstance'];
$menuDef[$this->displayedLinkHandlerId]['isActive'] = true;
}
return $menuDef;
}
/**
* @return string[]
*/
protected function getAllowedItems(): array
{
$allowedItems = $this->eventDispatcher
->dispatch(new ModifyAllowedItemsEvent(array_keys($this->linkHandlers), $this->currentLinkParts))
->getAllowedItems();
if (isset($this->parameters['params']['allowedTypes'])) {
$allowedItems = array_intersect($allowedItems, GeneralUtility::trimExplode(',', $this->parameters['params']['allowedTypes'], true));
} elseif (isset($this->parameters['params']['blindLinkOptions'])) {
// @todo Deprecate this option
$allowedItems = array_diff($allowedItems, GeneralUtility::trimExplode(',', $this->parameters['params']['blindLinkOptions'], true));
}
return $allowedItems;
}
/**
* @return string[]
*/
protected function getAllowedLinkAttributes(): array
{
$allowedLinkAttributes = $this->displayedLinkHandler->getLinkAttributes();
if (isset($this->parameters['params']['allowedOptions'])) {
$allowedLinkAttributes = array_intersect($allowedLinkAttributes, GeneralUtility::trimExplode(',', $this->parameters['params']['allowedOptions'], true));
} elseif (isset($this->parameters['params']['blindLinkFields'])) {
// @todo Deprecate this option
$allowedLinkAttributes = array_diff($allowedLinkAttributes, GeneralUtility::trimExplode(',', $this->parameters['params']['blindLinkFields'], true));
}
return $allowedLinkAttributes;
}
/**
* Renders the link attributes for the selected link handler
*/
protected function renderLinkAttributeFields(ViewInterface $view): string
{
$fieldRenderingDefinitions = $this->getLinkAttributeFieldDefinitions();
$fieldRenderingDefinitions = $this->displayedLinkHandler->modifyLinkAttributes($fieldRenderingDefinitions);
$this->linkAttributeFields = $this->getAllowedLinkAttributes();
$content = '';
foreach ($this->linkAttributeFields as $attribute) {
$content .= $fieldRenderingDefinitions[$attribute] ?? '';
}
$view->assign('allowedLinkAttributes', array_combine($this->linkAttributeFields, $this->linkAttributeFields));
// add update button if appropriate
if (!empty($this->currentLinkParts) && $this->displayedLinkHandler === $this->currentLinkHandler && $this->currentLinkHandler->isUpdateSupported()) {
$view->assign('showUpdateParametersButton', true);
}
return $content;
}
/**
* Create an array of link attribute field rendering definitions
*
* @return string[]
*/
protected function getLinkAttributeFieldDefinitions(): array
{
$lang = $this->getLanguageService();
$fieldRenderingDefinitions = [];
$fieldRenderingDefinitions['target'] = '
<!-- Selecting target for link: -->
<div class="element-browser-form-group">
<label for="ltarget" class="form-label">
' . htmlspecialchars($lang->sL('LLL:EXT:backend/Resources/Private/Language/locallang_browse_links.xlf:target')) . '
</label>
<typo3-backend-combobox>
<input id="ltarget" type="text" name="ltarget" class="form-control" value="' . htmlspecialchars($this->linkAttributeValues['target'] ?? '') . '" />
<typo3-backend-combobox-choice value="_top" icon="actions-window">' . $lang->sL('LLL:EXT:backend/Resources/Private/Language/locallang_browse_links.xlf:top') . '</typo3-backend-combobox-choice>
<typo3-backend-combobox-choice value="_blank" icon="actions-window-open">' . $lang->sL('LLL:EXT:backend/Resources/Private/Language/locallang_browse_links.xlf:newWindow') . '</typo3-backend-combobox-choice>
</typo3-backend-combobox>
</div>';
$fieldRenderingDefinitions['title'] = '
<!-- Selecting title for link: -->
<div class="element-browser-form-group">
<label for="ltitle" class="form-label">' . htmlspecialchars($lang->sL('LLL:EXT:backend/Resources/Private/Language/locallang_browse_links.xlf:title')) . '</label>
<input id="ltitle" type="text" name="ltitle" class="form-control"
value="' . htmlspecialchars($this->linkAttributeValues['title'] ?? '') . '" />
</div>';
$fieldRenderingDefinitions['class'] = '
<!-- Selecting class for link: -->
<div class="element-browser-form-group">
<label for="lclass" class="form-label">
' . htmlspecialchars($lang->sL('LLL:EXT:backend/Resources/Private/Language/locallang_browse_links.xlf:class')) . '
</label>
<input id="lclass" type="text" name="lclass" class="form-control"
value="' . htmlspecialchars($this->linkAttributeValues['class'] ?? '') . '" />
</div>';
$fieldRenderingDefinitions['params'] = '
<!-- Selecting params for link: -->
<div class="element-browser-form-group">
<label for="lparams" class="form-label">' . htmlspecialchars($lang->sL('LLL:EXT:backend/Resources/Private/Language/locallang_browse_links.xlf:params')) . '</label>
<input id="lparams" type="text" name="lparams" class="form-control"
value="' . htmlspecialchars($this->linkAttributeValues['params'] ?? '') . '" />
</div>';
$fieldRenderingDefinitions['rel'] = '
<!-- Selecting rel for link: -->
<div class="element-browser-form-group">
<label for="lrel" class="form-label">' . htmlspecialchars($lang->sL('backend.browse_links:linkRelationship')) . '</label>
<input id="lrel" type="text" name="lrel" class="form-control"
value="' . htmlspecialchars($this->linkAttributeValues['rel'] ?? '') . '" />
</div>';
$fieldRenderingDefinitions['download'] = '
<!-- Selecting download for link: -->
<div class="element-browser-form-group">
<typo3-backend-link-browser-download
value="' . htmlspecialchars($this->linkAttributeValues['download'] ?? '') . '"
label-download="' . htmlspecialchars($lang->sL('LLL:EXT:backend/Resources/Private/Language/locallang_browse_links.xlf:download')) . '"
label-filename="' . htmlspecialchars($lang->sL('LLL:EXT:backend/Resources/Private/Language/locallang_browse_links.xlf:download.customFilename')) . '"
></typo3-backend-link-browser-download>
</div>';
return $fieldRenderingDefinitions;
}
/**
* @return string[] Array of body-tag attributes
*/
protected function getBodyTagAttributes(): array
{
$attributes = $this->displayedLinkHandler->getBodyTagAttributes();
return array_merge(
$attributes,
[
'data-linkbrowser-parameters' => json_encode($this->parameters) ?: '',
'data-linkbrowser-attribute-fields' => json_encode(array_values($this->linkAttributeFields)) ?: '',
]
);
}
protected function getDisplayedLinkHandlerId(): string
{
return $this->displayedLinkHandlerId;
}
protected function getLanguageService(): LanguageService
{
return $GLOBALS['LANG'];
}
protected function getBackendUser(): BackendUserAuthentication
{
return $GLOBALS['BE_USER'];
}
}
@@ -0,0 +1,115 @@
<?php
declare(strict_types=1);
/*
* 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 TYPO3\CMS\Backend\Controller;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
use TYPO3\CMS\Core\Authentication\Mfa\MfaProviderManifestInterface;
use TYPO3\CMS\Core\Authentication\Mfa\MfaProviderRegistry;
use TYPO3\CMS\Core\Localization\LanguageService;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Abstract class for mfa controllers (configuration and authentication)
*
* @internal This class is a specific Backend controller implementation and is not considered part of the Public TYPO3 API.
*/
abstract class AbstractMfaController
{
protected MfaProviderRegistry $mfaProviderRegistry;
protected array $mfaTsConfig;
protected bool $mfaRequired;
protected array $allowedProviders;
protected array $allowedActions = [];
public function injectMfaProviderRegistry(MfaProviderRegistry $mfaProviderRegistry): void
{
$this->mfaProviderRegistry = $mfaProviderRegistry;
}
/**
* Main action for handling the request and returning the response
*/
abstract public function handleRequest(ServerRequestInterface $request): ResponseInterface;
protected function isActionAllowed(string $action): bool
{
return in_array($action, $this->allowedActions, true);
}
protected function isProviderAllowed(string $identifier): bool
{
return isset($this->allowedProviders[$identifier]);
}
protected function isValidIdentifier(string $identifier): bool
{
return $identifier !== ''
&& $this->isProviderAllowed($identifier)
&& $this->mfaProviderRegistry->hasProvider($identifier);
}
/**
* Initialize MFA configuration based on TSconfig and global configuration
*/
protected function initializeMfaConfiguration(): void
{
$backendUser = $this->getBackendUser();
$this->mfaTsConfig = $backendUser->getTSConfig()['auth.']['mfa.'] ?? [];
$this->mfaRequired = $backendUser->isMfaSetupRequired();
// Set up allowed providers based on user TSconfig and user groupData
$this->allowedProviders = array_filter($this->mfaProviderRegistry->getProviders(), function (string $identifier) use ($backendUser): bool {
return $backendUser->check('mfa_providers', $identifier)
&& !GeneralUtility::inList(($this->mfaTsConfig['disableProviders'] ?? ''), $identifier);
}, ARRAY_FILTER_USE_KEY);
}
/**
* Get the recommended provider
*/
protected function getRecommendedProvider(): ?MfaProviderManifestInterface
{
$recommendedProviderIdentifier = (string)($this->mfaTsConfig['recommendedProvider'] ?? '');
// Check if valid and allowed to be default provider, which is obviously a prerequisite
if (!$this->isValidIdentifier($recommendedProviderIdentifier)
|| !$this->mfaProviderRegistry->getProvider($recommendedProviderIdentifier)->isDefaultProviderAllowed()
) {
// If the provider, defined in user TSconfig is not valid or is not set, check the globally defined
$recommendedProviderIdentifier = (string)($GLOBALS['TYPO3_CONF_VARS']['BE']['recommendedMfaProvider'] ?? '');
if (!$this->isValidIdentifier($recommendedProviderIdentifier)
|| !$this->mfaProviderRegistry->getProvider($recommendedProviderIdentifier)->isDefaultProviderAllowed()
) {
// If also not valid or not set, return
return null;
}
}
return $this->mfaProviderRegistry->getProvider($recommendedProviderIdentifier);
}
protected function getBackendUser(): BackendUserAuthentication
{
return $GLOBALS['BE_USER'];
}
protected function getLanguageService(): LanguageService
{
return $GLOBALS['LANG'];
}
}
+158
View File
@@ -0,0 +1,158 @@
<?php
declare(strict_types=1);
/*
* 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 TYPO3\CMS\Backend\Controller;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Backend\Attribute\AsController;
use TYPO3\CMS\Backend\Authentication\BackendLocker;
use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
use TYPO3\CMS\Core\Authentication\LoginType;
use TYPO3\CMS\Core\FormProtection\BackendFormProtection;
use TYPO3\CMS\Core\FormProtection\FormProtectionFactory;
use TYPO3\CMS\Core\Http\JsonResponse;
use TYPO3\CMS\Core\Session\UserSessionManager;
/**
* This is the ajax handler for backend login after timeout.
* @internal This class is a specific Backend controller implementation and is not considered part of the Public TYPO3 API.
*/
#[AsController]
readonly class AjaxLoginController
{
public function __construct(
protected FormProtectionFactory $formProtectionFactory,
protected BackendLocker $lockService,
) {}
/**
* Handles the actual login process, more specifically it defines the response.
* The login details were sent in as part of the ajax request and automatically logged in
* the user inside the BackendUserAuthenticator middleware. If that was successful, we have
* a BE user and reset the timer and hide the login window.
* If it was unsuccessful, we display that and show the login box again.
*/
public function loginAction(ServerRequestInterface $request): ResponseInterface
{
if ($this->isAuthorizedBackendSession()) {
$result = ['success' => true];
if ($this->hasLoginBeenProcessed($request)) {
/** @var BackendFormProtection $formProtection */
$formProtection = $this->formProtectionFactory->createFromRequest($request);
$formProtection->setSessionTokenFromRegistry();
$formProtection->persistSessionToken();
}
} else {
$result = ['success' => false];
}
return new JsonResponse(['login' => $result]);
}
/**
* Logs out the current BE user
*/
public function logoutAction(ServerRequestInterface $request): ResponseInterface
{
$backendUser = $this->getBackendUser();
$backendUser->logoff();
return new JsonResponse([
'logout' => [
'success' => !isset($backendUser->user['uid']),
],
]);
}
public function preflightAction(ServerRequestInterface $request): ResponseInterface
{
$headers = $request->getHeaders();
return new JsonResponse([
'capabilities' => [
'cookie' => !empty($request->getCookieParams()),
// using legacy `Referer` (sic!) header name
'referrer' => array_filter($headers['referer'] ?? []) !== [],
],
]);
}
/**
* Handles the actual session refresh, more specifically it defines the response.
* The session refresh has been performed inside the BackendUserAuthenticator middleware.
* If that was successful, we have a BE user and report that information as response.
*/
public function refreshAction(ServerRequestInterface $request): ResponseInterface
{
$backendUser = $this->getBackendUser();
return new JsonResponse([
'refresh' => [
'success' => isset($backendUser->user['uid']),
],
]);
}
/**
* Checks if the user session is expired yet
*/
public function isTimedOutAction(ServerRequestInterface $request): ResponseInterface
{
$session = [
'timed_out' => false,
'will_time_out' => false,
'locked' => false,
];
$backendUser = $this->getBackendUser();
if ($this->lockService->isLocked()) {
$session['locked'] = true;
} elseif (!isset($backendUser->user['uid'])) {
$session['timed_out'] = true;
} else {
$sessionManager = UserSessionManager::create('BE');
// If 120 seconds from now is later than the session timeout, we need to show the refresh dialog.
// 120 is somewhat arbitrary to allow for a little room during the countdown and load times, etc.
$session['will_time_out'] = $sessionManager->willExpire($backendUser->getSession(), 120);
}
return new JsonResponse(['login' => $session]);
}
/**
* Checks if a user is logged in and the session is active.
*
* @return bool
*/
protected function isAuthorizedBackendSession()
{
$backendUser = $this->getBackendUser();
if ($backendUser === null) {
return false;
}
return isset($backendUser->user['uid']);
}
/**
* Check whether the user was already authorized or not
*/
protected function hasLoginBeenProcessed(ServerRequestInterface $request): bool
{
$loginFormData = $this->getBackendUser()->getLoginFormData($request);
return LoginType::tryFrom($loginFormData['status'] ?? '') === LoginType::LOGIN && !empty($loginFormData['uname']) && !empty($loginFormData['uident']);
}
protected function getBackendUser(): ?BackendUserAuthentication
{
return $GLOBALS['BE_USER'] ?? null;
}
}
+389
View File
@@ -0,0 +1,389 @@
<?php
declare(strict_types=1);
/*
* 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 TYPO3\CMS\Backend\Controller;
use Psr\EventDispatcher\EventDispatcherInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Backend\Attribute\AsController;
use TYPO3\CMS\Backend\Backend\Bookmark\BookmarkService;
use TYPO3\CMS\Backend\Controller\Event\AfterBackendPageRenderEvent;
use TYPO3\CMS\Backend\Controller\Event\BeforeBackendPageRenderEvent;
use TYPO3\CMS\Backend\Date\DateConfigurationFactory;
use TYPO3\CMS\Backend\Module\ModuleInterface;
use TYPO3\CMS\Backend\Module\ModuleProvider;
use TYPO3\CMS\Backend\Routing\Exception\RouteNotFoundException;
use TYPO3\CMS\Backend\Routing\Router;
use TYPO3\CMS\Backend\Routing\RouteRedirect;
use TYPO3\CMS\Backend\Routing\UriBuilder;
use TYPO3\CMS\Backend\Sidebar\Sidebar;
use TYPO3\CMS\Backend\Sidebar\SidebarComponentContext;
use TYPO3\CMS\Backend\Sidebar\SidebarFactory;
use TYPO3\CMS\Backend\Template\PageRendererBackendSetupTrait;
use TYPO3\CMS\Backend\Toolbar\RequestAwareToolbarItemInterface;
use TYPO3\CMS\Backend\Toolbar\ToolbarItemInterface;
use TYPO3\CMS\Backend\Toolbar\ToolbarItemsRegistry;
use TYPO3\CMS\Backend\View\BackendViewFactory;
use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
use TYPO3\CMS\Core\Configuration\ExtensionConfiguration;
use TYPO3\CMS\Core\Http\JsonResponse;
use TYPO3\CMS\Core\Information\Typo3Version;
use TYPO3\CMS\Core\Localization\LanguageService;
use TYPO3\CMS\Core\Messaging\FlashMessage;
use TYPO3\CMS\Core\Messaging\FlashMessageQueue;
use TYPO3\CMS\Core\Messaging\FlashMessageService;
use TYPO3\CMS\Core\Page\JavaScriptModuleInstruction;
use TYPO3\CMS\Core\Page\PageRenderer;
use TYPO3\CMS\Core\Routing\BackendEntryPointResolver;
use TYPO3\CMS\Core\Type\ContextualFeedbackSeverity;
use TYPO3\CMS\Core\Type\File\ImageInfo;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Core\Utility\MathUtility;
use TYPO3\CMS\Core\Utility\PathUtility;
use TYPO3\CMS\Core\View\ViewInterface;
/**
* Class for rendering the TYPO3 backend.
* This is the backend outer main frame with topbar and module menu.
*/
#[AsController]
readonly class BackendController
{
use PageRendererBackendSetupTrait;
public function __construct(
protected Typo3Version $typo3Version,
protected UriBuilder $uriBuilder,
protected PageRenderer $pageRenderer,
protected ModuleProvider $moduleProvider,
protected ToolbarItemsRegistry $toolbarItemsRegistry,
protected SidebarFactory $sidebarFactory,
protected ExtensionConfiguration $extensionConfiguration,
protected BackendViewFactory $viewFactory,
protected EventDispatcherInterface $eventDispatcher,
protected FlashMessageService $flashMessageService,
protected BackendEntryPointResolver $backendEntryPointResolver,
protected BookmarkService $bookmarkService,
protected DateConfigurationFactory $dateConfigurationFactory,
) {}
/**
* Main function generating the BE scaffolding.
*/
public function mainAction(ServerRequestInterface $request): ResponseInterface
{
$backendUser = $this->getBackendUser();
$pageRenderer = $this->pageRenderer;
// apply nonce hint for elements that are shown in a modal
$pageRenderer->setApplyNonceHint(true);
$this->setUpBasicPageRendererForBackend($pageRenderer, $this->extensionConfiguration, $request, $this->getLanguageService());
$javaScriptRenderer = $pageRenderer->getJavaScriptRenderer();
$javaScriptRenderer->addGlobalAssignment(['window' => [
'name' => 'typo3-backend', // reset window name to a standardized value
'opener' => null, // remove any previously set opener value
]]);
$javaScriptRenderer->addJavaScriptModuleInstruction(
JavaScriptModuleInstruction::create('@typo3/backend/login-refresh.js')
->invoke('initialize', [
'intervalTime' => MathUtility::forceIntegerInRange((int)$GLOBALS['TYPO3_CONF_VARS']['BE']['sessionTimeout'] - 60, 60),
'requestTokenUrl' => (string)$this->uriBuilder->buildUriFromRoute('login_request_token'),
'loginFramesetUrl' => (string)$this->uriBuilder->buildUriFromRoute('login_frameset'),
'logoutUrl' => (string)$this->uriBuilder->buildUriFromRoute('logout'),
])
);
$javaScriptRenderer->addJavaScriptModuleInstruction(
JavaScriptModuleInstruction::create('@typo3/backend/broadcast-service.js')->invoke('listen')
);
$javaScriptRenderer->addJavaScriptModuleInstruction(
JavaScriptModuleInstruction::create('@typo3/backend/hotkeys/negotiator.js')
);
$javaScriptRenderer->addJavaScriptModuleInstruction(
JavaScriptModuleInstruction::create('@typo3/backend/hotkeys.js')
);
$javaScriptRenderer->addJavaScriptModuleInstruction(
JavaScriptModuleInstruction::create('@typo3/backend/user-settings-manager.js')
);
// load the storage API and fill the UC into the PersistentStorage, so no additional AJAX call is needed
$javaScriptRenderer->addJavaScriptModuleInstruction(
JavaScriptModuleInstruction::create('@typo3/backend/storage/persistent.js')
->invoke('load', $backendUser->uc)
);
// Initialize bookmark store with server data if bookmarks are enabled
if ($this->bookmarkService->isEnabled()) {
$javaScriptRenderer->addJavaScriptModuleInstruction(
JavaScriptModuleInstruction::create('@typo3/backend/bookmark/bookmark-store.js')
->invoke('initialize', $this->bookmarkService->getBookmarks(), $this->bookmarkService->getGroups())
);
}
$javaScriptRenderer->addGlobalAssignment([
'TYPO3' => [
'configuration' => [
'username' => htmlspecialchars($backendUser->user['username']),
'showRefreshLoginPopup' => (bool)($GLOBALS['TYPO3_CONF_VARS']['BE']['showRefreshLoginPopup'] ?? false),
],
],
]);
$javaScriptRenderer->includeAllImports();
// @todo: This loads a ton of labels into JS. This should be reviewed what is really needed.
// This could happen when the localization API gets an overhaul.
$pageRenderer->addInlineLanguageLabelFile('EXT:core/Resources/Private/Language/locallang_core.xlf');
$pageRenderer->addInlineLanguageLabelFile('EXT:core/Resources/Private/Language/locallang_misc.xlf');
$pageRenderer->addInlineLanguageLabelFile('EXT:backend/Resources/Private/Language/locallang_layout.xlf');
$pageRenderer->addInlineLanguageLabelFile('EXT:backend/Resources/Private/Language/locallang_settingseditor.xlf');
$pageRenderer->addInlineLanguageLabelFile('EXT:core/Resources/Private/Language/locallang_mod_web_list.xlf');
// @todo: We can not put this into the template since PageRendererViewHelper does not deal with namespace in addInlineSettings argument
$pageRenderer->addInlineSetting('ShowItem', 'moduleUrl', (string)$this->uriBuilder->buildUriFromRoute('show_item'));
$pageRenderer->addInlineSetting('Resource', 'thumbnailUrl', (string)$this->uriBuilder->buildUriFromRoute('resource_request_thumbnail'));
$pageRenderer->addInlineSetting('RecordHistory', 'moduleUrl', (string)$this->uriBuilder->buildUriFromRoute('record_history'));
$pageRenderer->addInlineSetting('NewRecord', 'moduleUrl', (string)$this->uriBuilder->buildUriFromRoute('db_new'));
$pageRenderer->addInlineSetting('FormEngine', 'moduleUrl', (string)$this->uriBuilder->buildUriFromRoute('record_edit'));
$pageRenderer->addInlineSetting('RecordCommit', 'moduleUrl', (string)$this->uriBuilder->buildUriFromRoute('tce_db'));
$pageRenderer->addInlineSetting('FileCommit', 'moduleUrl', (string)$this->uriBuilder->buildUriFromRoute('tce_file'));
$pageRenderer->addInlineSetting('Clipboard', 'moduleUrl', (string)$this->uriBuilder->buildUriFromRoute('clipboard_process'));
$pageRenderer->addInlineSetting('Wizards', 'elementBrowserUrl', (string)$this->uriBuilder->buildUriFromRoute('wizard_element_browser'));
// Needed for FormEngine manipulation (date picker) and DateTime components
$pageRenderer->addInlineSetting(null, 'DateConfiguration', $this->dateConfigurationFactory->getConfiguration('javascript'));
$typo3Version = 'TYPO3 CMS ' . $this->typo3Version->getVersion();
$title = $GLOBALS['TYPO3_CONF_VARS']['SYS']['sitename'] ? $GLOBALS['TYPO3_CONF_VARS']['SYS']['sitename'] . ' [' . $typo3Version . ']' : $typo3Version;
$pageRenderer->setTitle($title);
$sidebarContext = new SidebarComponentContext($request, $backendUser);
$sidebar = $this->sidebarFactory->create($sidebarContext);
$view = $this->viewFactory->create($request);
$this->assignTopbarDetailsToView($request, $view, $sidebar);
$startupModule = $this->getStartupModule($request);
$noModuleAccess = $startupModule[0] === null && empty($this->moduleProvider->getModulesForModuleMenu($backendUser));
$view->assignMultiple([
'startupModule' => $startupModule,
'noModuleAccess' => $noModuleAccess,
'workspaceAccessDenied' => $noModuleAccess && $backendUser->workspace === -99,
'entryPoint' => $this->backendEntryPointResolver->getPathFromRequest($request),
'stateTracker' => (string)$this->uriBuilder->buildUriFromRoute('state-tracker'),
'sitename' => $title,
'sitenameFirstInBackendTitle' => ($backendUser->uc['backendTitleFormat'] ?? '') === 'sitenameFirst',
'sidebar' => $sidebar->render(),
]);
$this->eventDispatcher->dispatch(new BeforeBackendPageRenderEvent($view, $javaScriptRenderer, $pageRenderer));
$content = $view->render('Backend/Main');
$content = $this->eventDispatcher->dispatch(new AfterBackendPageRenderEvent($content, $view))->getContent();
$pageRenderer->addBodyContent('<body>' . $content);
return $pageRenderer->renderResponse($request);
}
/**
* Returns the main module menu as json encoded HTML string. Used when
* "update signals" request a menu reload, e.g. when an extension is loaded
* that brings new main modules.
*/
public function getModuleMenu(ServerRequestInterface $request): ResponseInterface
{
$sidebarContext = new SidebarComponentContext($request, $this->getBackendUser());
$component = $this->sidebarFactory->create($sidebarContext)->getComponentByIdentifier('module-menu');
return new JsonResponse(['menu' => $component?->getResult($sidebarContext)->html]);
}
/**
* Returns the toolbar as json encoded HTML string. Used when
* "update signals" request a toolbar reload, e.g. when an extension is loaded.
*/
public function getTopbar(ServerRequestInterface $request): ResponseInterface
{
$sidebar = $this->sidebarFactory->create(new SidebarComponentContext($request, $this->getBackendUser()));
$view = $this->viewFactory->create($request);
$this->assignTopbarDetailsToView($request, $view, $sidebar);
return new JsonResponse(['topbar' => $view->render('Backend/Topbar')]);
}
/**
* Renders the topbar, containing the backend logo, sitename etc.
*/
protected function assignTopbarDetailsToView(ServerRequestInterface $request, ViewInterface $view, Sidebar $sidebar): void
{
// Extension Configuration to find the TYPO3 logo in the left corner
$extConf = $this->extensionConfiguration->get('backend');
$logoPath = '';
$logoUrl = '';
$logoWidth = 22;
$logoHeight = 22;
if (!empty($extConf['backendLogo'])) {
$configuredLogo = ltrim($extConf['backendLogo'], '/');
$customBackendLogo = GeneralUtility::getFileAbsFileName($configuredLogo);
if ($customBackendLogo !== '' && file_exists($customBackendLogo)) {
$logoPath = $customBackendLogo;
$logoUrl = (string)PathUtility::getSystemResourceUri($configuredLogo, $request);
// set width/height for custom logo
$imageInfo = GeneralUtility::makeInstance(ImageInfo::class, $logoPath);
$logoWidth = $imageInfo->getWidth() ?: $logoWidth;
$logoHeight = $imageInfo->getHeight() ?: $logoHeight;
// High-resolution?
if (str_contains($logoPath, '@2x.')) {
$logoWidth /= 2;
$logoHeight /= 2;
}
}
}
// if no custom logo was set or the path is invalid, use the original one
if ($logoPath === '') {
$logoUrl = (string)PathUtility::getSystemResourceUri('EXT:backend/Resources/Public/Images/typo3_logo_orange.svg', $request);
}
$view->assign('sidebar', $sidebar);
$view->assign('logoUrl', $logoUrl);
$view->assign('logoWidth', $logoWidth);
$view->assign('logoHeight', $logoHeight);
$view->assign('applicationVersion', $this->typo3Version->getVersion());
$view->assign('siteName', $GLOBALS['TYPO3_CONF_VARS']['SYS']['sitename']);
$view->assign('toolbarItems', $this->getToolbarItems($request));
$view->assign('isImpersonated', $this->getBackendUser()->getOriginalUserIdWhenInSwitchUserMode() !== null);
}
/**
* @return ToolbarItemInterface[]
*/
protected function getToolbarItems(ServerRequestInterface $request): array
{
return array_map(static function (ToolbarItemInterface $toolbarItem) use ($request): ToolbarItemInterface {
if ($toolbarItem instanceof RequestAwareToolbarItemInterface) {
$toolbarItem->setRequest($request);
}
return $toolbarItem;
}, array_filter(
$this->toolbarItemsRegistry->getToolbarItems(),
static fn(ToolbarItemInterface $toolbarItem): bool => $toolbarItem->checkAccess()
));
}
/**
* Sets the startup module from either "redirect" GET parameters or user configuration.
*
* @return array{?string, ?string}
*/
protected function getStartupModule(ServerRequestInterface $request): array
{
$startModule = null;
$startModuleIdentifier = null;
$inaccessibleRedirectModule = null;
$moduleParameters = [];
try {
$redirect = RouteRedirect::createFromRequest($request);
if ($redirect !== null && $request->getMethod() === 'GET') {
// Only redirect to existing non-ajax routes with no restriction to a specific method
$router = GeneralUtility::makeInstance(Router::class);
$redirect->resolve($router);
$module = $router->getRoute($redirect->getName())?->getOption('module');
if ($module instanceof ModuleInterface === false
|| $this->moduleProvider->accessGranted($module->getIdentifier(), $this->getBackendUser())
) {
// Only add start module from request in case user has access or it's a no module route,
// e.g. to FormEngine where permissions are checked by the corresponding component.
// Access might temporarily be blocked. e.g. due to being in a workspace.
$startModuleIdentifier = $redirect->getName();
$moduleParameters = $redirect->getParameters();
} elseif ($this->moduleProvider->isModuleRegistered($module->getIdentifier())) {
// A redirect is set, however, the user is not allowed to access the module.
// Store the requested module to later inform the user about the forced redirect.
$inaccessibleRedirectModule = $this->moduleProvider->getModule($module->getIdentifier());
}
}
} finally {
// No valid redirect, check for the start module
if (!$startModuleIdentifier) {
$backendUser = $this->getBackendUser();
// start module on first login, will be removed once used the first time
if (isset($backendUser->uc['startModuleOnFirstLogin'])) {
$startModuleIdentifier = $backendUser->uc['startModuleOnFirstLogin'];
unset($backendUser->uc['startModuleOnFirstLogin']);
$backendUser->writeUC();
} elseif (isset($backendUser->uc['startModule']) && $this->moduleProvider->accessGranted($backendUser->uc['startModule'], $backendUser)) {
$startModuleIdentifier = $backendUser->uc['startModule'];
} elseif ($firstAccessibleModule = $this->moduleProvider->getFirstAccessibleModule($backendUser)) {
$startModuleIdentifier = $firstAccessibleModule->getIdentifier();
}
// check if the start module has additional parameters, so a redirect to a specific
// action is possible
if (is_string($startModuleIdentifier) && str_contains($startModuleIdentifier, '->')) {
[$startModuleIdentifier, $startModuleParameters] = explode('->', $startModuleIdentifier, 2);
// if no GET parameters are set, check if there are parameters given from the UC
if (!$moduleParameters && $startModuleParameters) {
$moduleParameters = $startModuleParameters;
}
}
}
}
if ($startModuleIdentifier) {
if ($this->moduleProvider->isModuleRegistered($startModuleIdentifier)) {
// startModuleIdentifier may be an alias, resolve original module
$startModule = $this->moduleProvider->getModule($startModuleIdentifier, $this->getBackendUser());
$startModuleIdentifier = $startModule?->getIdentifier();
}
if (is_array($moduleParameters)) {
$parameters = $moduleParameters;
} else {
$parameters = [];
parse_str($moduleParameters, $parameters);
}
try {
$deepLink = $this->uriBuilder->buildUriFromRoute($startModuleIdentifier, $parameters);
if ($startModule !== null && $inaccessibleRedirectModule !== null) {
$this->enqueueRedirectMessage($inaccessibleRedirectModule, $startModule);
}
return [$startModuleIdentifier, (string)$deepLink];
} catch (RouteNotFoundException $e) {
// It might be, that the user does not have access to the
// $startModule, e.g. for modules with workspace restrictions.
}
}
return [null, null];
}
protected function enqueueRedirectMessage(ModuleInterface $requestedModule, ModuleInterface $redirectedModule): void
{
$languageService = $this->getLanguageService();
$this->flashMessageService
->getMessageQueueByIdentifier(FlashMessageQueue::NOTIFICATION_QUEUE)
->enqueue(
new FlashMessage(
sprintf(
$languageService->sL('LLL:EXT:backend/Resources/Private/Language/locallang.xlf:module.noAccess.message'),
$languageService->sL($redirectedModule->getTitle()),
$languageService->sL($requestedModule->getTitle())
),
$languageService->sL('LLL:EXT:backend/Resources/Private/Language/locallang.xlf:module.noAccess.title'),
ContextualFeedbackSeverity::INFO,
true
)
);
}
protected function getBackendUser(): BackendUserAuthentication
{
return $GLOBALS['BE_USER'];
}
protected function getLanguageService(): LanguageService
{
return $GLOBALS['LANG'];
}
}
+319
View File
@@ -0,0 +1,319 @@
<?php
declare(strict_types=1);
/*
* 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 TYPO3\CMS\Backend\Controller;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Backend\Attribute\AsController;
use TYPO3\CMS\Backend\Backend\Bookmark\BookmarkService;
use TYPO3\CMS\Core\Http\JsonResponse;
use TYPO3\CMS\Core\Localization\LanguageService;
/**
* Controller for bookmark processing.
*
* @internal This class is a specific Backend controller implementation and is not considered part of the Public TYPO3 API.
*/
#[AsController]
readonly class BookmarkController
{
public function __construct(
protected BookmarkService $bookmarkService,
) {}
public function listAction(ServerRequestInterface $request): ResponseInterface
{
return new JsonResponse([
'success' => true,
'bookmarks' => $this->bookmarkService->getBookmarks(),
'groups' => $this->bookmarkService->getGroups(),
]);
}
public function createAction(ServerRequestInterface $request): ResponseInterface
{
$parsedBody = $request->getParsedBody();
$routeIdentifier = $parsedBody['routeIdentifier'] ?? '';
$arguments = $parsedBody['arguments'] ?? '';
if ($routeIdentifier === '') {
return $this->errorResponse(
'core.bookmarks:error.missingRoute.message',
400
);
}
if ($this->bookmarkService->hasBookmark($routeIdentifier, $arguments)) {
return $this->errorResponse(
'core.bookmarks:error.createFailed.message'
);
}
$bookmarkName = $parsedBody['displayName'] ?? '';
$bookmarkId = $this->bookmarkService->createBookmark($routeIdentifier, $arguments, $bookmarkName);
if ($bookmarkId === false) {
return $this->errorResponse(
'core.bookmarks:error.createFailed.message',
500
);
}
$bookmark = $this->bookmarkService->getBookmark($bookmarkId);
return new JsonResponse([
'success' => true,
'bookmark' => $bookmark,
], 201);
}
public function updateAction(ServerRequestInterface $request): ResponseInterface
{
$parsedBody = $request->getParsedBody();
$bookmarkId = (int)($parsedBody['bookmarkId'] ?? 0);
$bookmarkTitle = trim($parsedBody['bookmarkTitle'] ?? '');
// Group ID can be int (system group, including negative for global) or string (user-created UUID)
$bookmarkGroupId = $parsedBody['bookmarkGroup'] ?? 0;
if (is_numeric($bookmarkGroupId)) {
$bookmarkGroupId = (int)$bookmarkGroupId;
}
if ($bookmarkId === 0) {
return $this->errorResponse(
'core.bookmarks:error.missingBookmarkId.message',
400
);
}
$result = $this->bookmarkService->updateBookmark($bookmarkId, $bookmarkTitle, $bookmarkGroupId);
if ($result['success']) {
$bookmark = $this->bookmarkService->getBookmark($bookmarkId);
if ($bookmark !== null) {
$result['bookmark'] = $bookmark;
}
}
return new JsonResponse($result);
}
public function deleteAction(ServerRequestInterface $request): ResponseInterface
{
$bookmarkId = (int)($request->getParsedBody()['bookmarkId'] ?? 0);
if ($bookmarkId === 0) {
return $this->errorResponse(
'core.bookmarks:error.missingBookmarkId.message',
400
);
}
$result = $this->bookmarkService->deleteBookmark($bookmarkId);
return new JsonResponse($result);
}
public function reorderAction(ServerRequestInterface $request): ResponseInterface
{
$parsedBody = $request->getParsedBody();
$bookmarkIds = $parsedBody['bookmarkIds'] ?? [];
if (!is_array($bookmarkIds) || $bookmarkIds === []) {
return $this->errorResponse(
'core.bookmarks:error.missingBookmarkIds.message',
400
);
}
$success = $this->bookmarkService->reorderBookmarks(array_map('intval', $bookmarkIds));
return new JsonResponse([
'success' => $success,
'bookmarks' => $this->bookmarkService->getBookmarks(),
]);
}
public function deleteMultipleAction(ServerRequestInterface $request): ResponseInterface
{
$parsedBody = $request->getParsedBody();
$bookmarkIds = $parsedBody['bookmarkIds'] ?? [];
if (!is_array($bookmarkIds) || $bookmarkIds === []) {
return $this->errorResponse(
'core.bookmarks:error.missingBookmarkIds.message',
400
);
}
$success = $this->bookmarkService->deleteBookmarks(array_map('intval', $bookmarkIds));
return new JsonResponse(['success' => $success]);
}
public function moveAction(ServerRequestInterface $request): ResponseInterface
{
$parsedBody = $request->getParsedBody();
$bookmarkIds = $parsedBody['bookmarkIds'] ?? [];
// Group ID can be int (system group, including negative for global) or string (user-created UUID)
$groupId = $parsedBody['groupId'] ?? 0;
if (is_numeric($groupId)) {
$groupId = (int)$groupId;
}
if (!is_array($bookmarkIds) || $bookmarkIds === []) {
return $this->errorResponse(
'core.bookmarks:error.missingBookmarkIds.message',
400
);
}
$success = $this->bookmarkService->moveBookmarks(array_map('intval', $bookmarkIds), $groupId);
return new JsonResponse(['success' => $success]);
}
public function createGroupAction(ServerRequestInterface $request): ResponseInterface
{
$parsedBody = $request->getParsedBody();
$label = trim($parsedBody['label'] ?? '');
if ($label === '') {
return $this->errorResponse(
'core.bookmarks:error.missingLabel.message',
400
);
}
$group = $this->bookmarkService->createGroup($label);
if ($group === null) {
return $this->errorResponse(
'core.bookmarks:error.groupCreateFailed.message',
500
);
}
return new JsonResponse([
'success' => true,
'group' => $group,
], 201);
}
public function updateGroupAction(ServerRequestInterface $request): ResponseInterface
{
$parsedBody = $request->getParsedBody();
$uuid = trim($parsedBody['uuid'] ?? '');
$label = trim($parsedBody['label'] ?? '');
if ($uuid === '') {
return $this->errorResponse(
'core.bookmarks:error.missingGroupId.message',
400
);
}
if ($label === '') {
return $this->errorResponse(
'core.bookmarks:error.missingLabel.message',
400
);
}
$success = $this->bookmarkService->updateGroup($uuid, $label);
if (!$success) {
return $this->errorResponse(
'core.bookmarks:error.groupUpdateFailed.message',
500
);
}
return new JsonResponse([
'success' => true,
'groups' => $this->bookmarkService->getGroups(),
]);
}
public function deleteGroupAction(ServerRequestInterface $request): ResponseInterface
{
$parsedBody = $request->getParsedBody();
$uuid = trim($parsedBody['uuid'] ?? '');
if ($uuid === '') {
return $this->errorResponse(
'core.bookmarks:error.missingGroupId.message',
400
);
}
$success = $this->bookmarkService->deleteGroup($uuid);
if (!$success) {
return $this->errorResponse(
'core.bookmarks:error.groupDeleteFailed.message',
500
);
}
return new JsonResponse([
'success' => true,
'groups' => $this->bookmarkService->getGroups(),
]);
}
public function reorderGroupsAction(ServerRequestInterface $request): ResponseInterface
{
$parsedBody = $request->getParsedBody();
$uuids = $parsedBody['uuids'] ?? [];
if (!is_array($uuids) || $uuids === []) {
return $this->errorResponse(
'core.bookmarks:error.missingGroupIds.message',
400
);
}
$success = $this->bookmarkService->reorderGroups($uuids);
if (!$success) {
return $this->errorResponse(
'core.bookmarks:error.groupReorderFailed.message',
500
);
}
return new JsonResponse([
'success' => true,
'groups' => $this->bookmarkService->getGroups(),
]);
}
private function errorResponse(string $labelKey, int $statusCode = 200): JsonResponse
{
$languageService = $this->getLanguageService();
return new JsonResponse([
'success' => false,
'error' => $languageService->sL($labelKey) ?: $labelKey,
], $statusCode);
}
private function getLanguageService(): LanguageService
{
return $GLOBALS['LANG'];
}
}
@@ -0,0 +1,98 @@
<?php
declare(strict_types=1);
/*
* 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 TYPO3\CMS\Backend\Controller;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Backend\Utility\BackendUtility;
use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
use TYPO3\CMS\Core\DataHandling\DataHandler;
use TYPO3\CMS\Core\Http\JsonResponse;
use TYPO3\CMS\Core\Localization\LanguageService;
use TYPO3\CMS\Core\Type\Bitmask\Permission;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Handles clearing caches from the clear cache toolbar item and the records module.
*
* @internal This class is a specific Backend controller implementation and is not part of the TYPO3's Core API.
*/
readonly class ClearCacheController
{
public function flushCacheGroupPagesAction(ServerRequestInterface $request): ResponseInterface
{
$dataHandler = GeneralUtility::makeInstance(DataHandler::class);
$dataHandler->start([], []);
$dataHandler->clear_cacheCmd('pages');
$languageService = $this->getLanguageService();
return new JsonResponse([
'success' => true,
'title' => $languageService->sL('core.cache:notification.group.pages.success.title'),
'message' => $languageService->sL('core.cache:notification.group.pages.success.message'),
]);
}
public function flushCacheGroupAllAction(ServerRequestInterface $request): ResponseInterface
{
$dataHandler = GeneralUtility::makeInstance(DataHandler::class);
$dataHandler->start([], []);
$dataHandler->clear_cacheCmd('all');
$languageService = $this->getLanguageService();
return new JsonResponse([
'success' => true,
'title' => $languageService->sL('core.cache:notification.group.all.success.title'),
'message' => $languageService->sL('core.cache:notification.group.all.success.message'),
]);
}
public function flushCachePageAction(ServerRequestInterface $request): ResponseInterface
{
$parsedBody = $request->getParsedBody();
$pageUid = (int)($parsedBody['id'] ?? 0);
$languageService = $this->getLanguageService();
$permissionClause = $this->getBackendUser()->getPagePermsClause(Permission::PAGE_SHOW);
$pageRow = BackendUtility::readPageAccess($pageUid, $permissionClause);
if ($pageUid !== 0 && $this->getBackendUser()->doesUserHaveAccess($pageRow, Permission::PAGE_SHOW)) {
$dataHandler = GeneralUtility::makeInstance(DataHandler::class);
$dataHandler->start([], []);
$dataHandler->clear_cacheCmd($pageUid);
return new JsonResponse([
'success' => true,
'title' => $languageService->sL('core.cache:notification.page.success.title'),
'message' => sprintf($languageService->sL('core.cache:notification.page.success.message'), BackendUtility::getRecordTitle('pages', $pageRow)),
]);
}
return new JsonResponse([
'success' => false,
'title' => $languageService->sL('core.cache:notification.page.error.title'),
'message' => $languageService->sL('core.cache:notification.page.error.message'),
]);
}
protected function getBackendUser(): BackendUserAuthentication
{
return $GLOBALS['BE_USER'];
}
protected function getLanguageService(): LanguageService
{
return $GLOBALS['LANG'];
}
}
+113
View File
@@ -0,0 +1,113 @@
<?php
declare(strict_types=1);
/*
* 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 TYPO3\CMS\Backend\Controller;
use Psr\Http\Message\ResponseFactoryInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\StreamFactoryInterface;
use TYPO3\CMS\Backend\Attribute\AsController;
use TYPO3\CMS\Backend\Clipboard\Clipboard;
use TYPO3\CMS\Core\Localization\LanguageService;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Controller which behaves as endpoint for clipboard requests, dispatched from
* either the clipboard panel web component or any of the corresponding modules.
*
* @internal This class is a specific Backend controller implementation and is not part of the TYPO3's Core API.
*/
#[AsController]
class ClipboardController
{
private const array ALLOWED_ACTIONS = ['getClipboardData'];
protected ResponseFactoryInterface $responseFactory;
protected StreamFactoryInterface $streamFactory;
protected Clipboard $clipboard;
public function __construct(ResponseFactoryInterface $responseFactory, StreamFactoryInterface $streamFactory)
{
$this->responseFactory = $responseFactory;
$this->streamFactory = $streamFactory;
$this->clipboard = GeneralUtility::makeInstance(Clipboard::class);
}
/**
* Process incoming clipboard request
*/
public function processRequest(ServerRequestInterface $request): ResponseInterface
{
$this->clipboard->initializeClipboard($request);
$CB = (array)($request->getParsedBody()['CB'] ?? []);
if ($CB !== []) {
// Execute commands.
$this->clipboard->setCmd($CB);
}
// Clean up pad
$this->clipboard->cleanCurrent();
// Save the clipboard content
$this->clipboard->endClipboard();
$action = (string)($request->getQueryParams()['action'] ?? '');
if (in_array($action, self::ALLOWED_ACTIONS, true)) {
return $this->{$action . 'Action'}($request);
}
// Default response in case no dedicated action is requested.
// This is usually done if only internal clipboard state is changed.
return $this->createResponse(['success' => true, 'data' => []]);
}
protected function getClipboardDataAction(ServerRequestInterface $request): ResponseInterface
{
$clipboardData = $this->clipboard->getClipboardData($request->getParsedBody()['table'] ?? '');
// Add labels for the panel
$lang = $this->getLanguageService();
$clipboardLabels = [
'clipboard' => $lang->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:buttons.clipboard'),
'copyElements' => $lang->sL('LLL:EXT:core/Resources/Private/Language/locallang_misc.xlf:copyElements'),
'moveElements' => $lang->sL('LLL:EXT:core/Resources/Private/Language/locallang_misc.xlf:moveElements'),
'copy' => $lang->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:cm.copy'),
'cut' => $lang->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:cm.cut'),
'info' => $lang->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:cm.info'),
'removeAll' => $lang->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:buttons.removeAll'),
'removeItem' => $lang->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.removeItem'),
];
return $this->createResponse([
'success' => $clipboardData !== [],
'data' => array_merge($clipboardData, ['labels' => $clipboardLabels]),
]);
}
protected function createResponse(array $data): ResponseInterface
{
return $this->responseFactory->createResponse()
->withHeader('Content-Type', 'application/json; charset=utf-8')
->withBody($this->streamFactory->createStream(json_encode($data)));
}
protected function getLanguageService(): LanguageService
{
return $GLOBALS['LANG'];
}
}
@@ -0,0 +1,129 @@
<?php
declare(strict_types=1);
/*
* 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 TYPO3\CMS\Backend\Controller\CodeEditor;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Backend\Attribute\AsController;
use TYPO3\CMS\Core\Http\HtmlResponse;
use TYPO3\CMS\Core\Http\JsonResponse;
use TYPO3\CMS\Core\Localization\LanguageService;
use TYPO3\CMS\Core\Site\Entity\SiteInterface;
use TYPO3\CMS\Core\TypoScript\IncludeTree\SysTemplateRepository;
use TYPO3\CMS\Core\TypoScript\IncludeTree\SysTemplateTreeBuilder;
use TYPO3\CMS\Core\TypoScript\IncludeTree\Traverser\IncludeTreeTraverser;
use TYPO3\CMS\Core\TypoScript\IncludeTree\Visitor\IncludeTreeAstBuilderVisitor;
use TYPO3\CMS\Core\TypoScript\Tokenizer\LossyTokenizer;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Core\Utility\RootlineUtility;
/**
* Code completion for code editor
*
* @internal This is a specific Backend Controller implementation and is not considered part of the Public TYPO3 API.
*/
#[AsController]
readonly class CodeCompletionController
{
public function __construct(
private SysTemplateRepository $sysTemplateRepository,
private SysTemplateTreeBuilder $treeBuilder,
private LossyTokenizer $lossyTokenizer,
private IncludeTreeTraverser $treeTraverser,
) {}
/**
* Loads all templates up to a given page id (walking the rootline) and
* cleans parts that are not required for the code editor code-completion.
*/
public function loadCompletions(ServerRequestInterface $request): ResponseInterface
{
// Check whether access is granted (only admin have access to sys_template records):
if (!$GLOBALS['BE_USER']->isAdmin()) {
return new HtmlResponse($this->getLanguageService()->sL('LLL:EXT:backend/Resources/Private/Language/locallang_codeeditor.xlf:noPermission'), 500);
}
$pageId = (int)($request->getParsedBody()['pageId'] ?? $request->getQueryParams()['pageId']);
// Check whether there is a pageId given:
if (!$pageId) {
return new HtmlResponse($this->getLanguageService()->sL('LLL:EXT:backend/Resources/Private/Language/locallang_codeeditor.xlf:pageIDInteger'), 500);
}
// Fetch the templates
return new JsonResponse($this->getMergedTemplates($pageId, $request));
}
/**
* Gets merged templates by walking the rootline to a given page id.
* This is loaded once via ajax when a code editor in typoscript mode is fired.
* JS then knows the object types and can auto-complete on CTRL+space.
*
* @return array Setup part of merged template records
*/
protected function getMergedTemplates(int $pageId, ServerRequestInterface $request): array
{
$rootLine = GeneralUtility::makeInstance(RootlineUtility::class, $pageId)->get();
$sysTemplateRows = $this->sysTemplateRepository->getSysTemplateRowsByRootline($rootLine, $request);
/** @var SiteInterface|null $site */
$site = $request->getAttribute('site');
$setupIncludeTree = $this->treeBuilder->getTreeBySysTemplateRowsAndSite('setup', $sysTemplateRows, $this->lossyTokenizer, $site);
$setupAstBuilderVisitor = GeneralUtility::makeInstance(IncludeTreeAstBuilderVisitor::class);
$this->treeTraverser->traverse($setupIncludeTree, [$setupAstBuilderVisitor]);
$setupAst = $setupAstBuilderVisitor->getAst();
return $this->treeWalkCleanup($setupAst->toArray());
}
/**
* Walks through a tree of TypoScript configuration and prepares it for JS.
*/
private function treeWalkCleanup(array $treeBranch): array
{
$cleanedTreeBranch = [];
foreach ($treeBranch as $key => $value) {
$key = is_int($key) ? (string)$key : $key;
//type definition or value-assignment
if (substr($key, -1) !== '.') {
if ($value != '') {
if (mb_strlen($value) > 20) {
$value = mb_substr($value, 0, 20);
}
if (!isset($cleanedTreeBranch[$key])) {
$cleanedTreeBranch[$key] = [];
}
$cleanedTreeBranch[$key]['v'] = $value;
}
} else {
// subtree (definition of properties)
$subBranch = $this->treeWalkCleanup($value);
if ($subBranch) {
if (substr($key, -1) === '.') {
$key = rtrim($key, '.');
}
if (!isset($cleanedTreeBranch[$key])) {
$cleanedTreeBranch[$key] = [];
}
$cleanedTreeBranch[$key]['c'] = $subBranch;
}
}
}
return $cleanedTreeBranch;
}
protected function getLanguageService(): LanguageService
{
return $GLOBALS['LANG'];
}
}
@@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
/*
* 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 TYPO3\CMS\Backend\Controller\CodeEditor;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Core\Http\JsonResponse;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Loads TSref information from a XML file and responds to an AJAX call.
* @internal This is a specific Backend Controller implementation and is not considered part of the Public TYPO3 API.
*/
class TypoScriptReferenceController
{
/**
* Load TypoScript reference
*/
public function loadReference(ServerRequestInterface $request): ResponseInterface
{
// Load the TSref XML information
$xmlDoc = new \DOMDocument('1.0', 'utf-8');
$xmlDoc->loadXML(file_get_contents(GeneralUtility::getFileAbsFileName('EXT:backend/Resources/Private/tsref.xml')));
return new JsonResponse($this->getTypes($xmlDoc));
}
/**
* Get types from XML
*/
protected function getTypes(\DOMDocument $xmlDoc): array
{
$types = $xmlDoc->getElementsByTagName('type');
$typeArr = [];
foreach ($types as $type) {
$typeId = $type->getAttribute('id');
$typeName = $type->getAttribute('name');
if (!$typeName) {
$typeName = $typeId;
}
$properties = $type->getElementsByTagName('property');
$propArr = [];
foreach ($properties as $property) {
$p = [];
$p['name'] = $property->getAttribute('name');
$p['type'] = $property->getAttribute('type');
$propArr[$property->getAttribute('name')] = $p;
}
$typeArr[$typeId] = [];
$typeArr[$typeId]['properties'] = $propArr;
$typeArr[$typeId]['name'] = $typeName;
if ($type->hasAttribute('extends')) {
$typeArr[$typeId]['extends'] = $type->getAttribute('extends');
}
}
return $typeArr;
}
}
@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
/*
* 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 TYPO3\CMS\Backend\Controller;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Backend\Attribute\AsController;
use TYPO3\CMS\Backend\Backend\ColorScheme;
use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
use TYPO3\CMS\Core\Http\JsonResponse;
use TYPO3\CMS\Core\Http\Response;
#[AsController]
class ColorSchemeController
{
public function updateAction(ServerRequestInterface $request): ResponseInterface
{
$colorScheme = $request->getParsedBody()['colorScheme'];
if ($request->getMethod() !== 'POST' || !ColorScheme::tryFrom($colorScheme)) {
return new JsonResponse(null, 400);
}
$backendUser = $this->getBackendUser();
$backendUser->uc['colorScheme'] = $colorScheme;
$backendUser->writeUC();
return new Response(null);
}
protected function getBackendUser(): BackendUserAuthentication
{
return $GLOBALS['BE_USER'];
}
}
@@ -0,0 +1,242 @@
<?php
declare(strict_types=1);
/*
* 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 TYPO3\CMS\Backend\Controller;
use Psr\Http\Message\ResponseFactoryInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Backend\Attribute\AsController;
use TYPO3\CMS\Backend\Utility\BackendUtility;
use TYPO3\CMS\Backend\View\BackendViewFactory;
use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
use TYPO3\CMS\Core\Localization\LanguageService;
use TYPO3\CMS\Core\Schema\Capability\TcaSchemaCapability;
use TYPO3\CMS\Core\Schema\TcaSchemaFactory;
use TYPO3\CMS\Core\View\ViewInterface;
/**
* Controller for handling the display column selection for records, typically executed from list modules.
*
* @internal This class is a specific Backend controller implementation and is not part of the TYPO3's Core API.
*/
#[AsController]
readonly class ColumnSelectorController
{
private const array PSEUDO_FIELDS = ['_REF_', '_PATH_'];
private const array EXCLUDE_FILE_FIELDS = [
'pid', // Not relevant as all records are on pid=0
'identifier', // Handled manually in listing
'name', // Handled manually in listing
'metadata', // The reference to the meta data is not relevant
'file', // The reference to the file is not relevant
'sys_language_uid', // Not relevant in listing since only default is displayed
'l10n_parent', // Not relevant in listing
't3ver_state', // Not relevant in listing
't3ver_wsid', // Not relevant in listing
't3ver_oid', // Not relevant in listing
];
public function __construct(
protected ResponseFactoryInterface $responseFactory,
protected BackendViewFactory $backendViewFactory,
protected TcaSchemaFactory $tcaSchemaFactory,
) {}
/**
* Update the columns to be displayed for the given table
*/
public function updateVisibleColumnsAction(ServerRequestInterface $request): ResponseInterface
{
$parsedBody = $request->getParsedBody();
$table = (string)($parsedBody['table'] ?? '');
$selectedColumns = $parsedBody['selectedColumns'] ?? [];
if ($table === '' || !is_array($selectedColumns)) {
return $this->jsonResponse([
'success' => false,
'message' => htmlspecialchars(
$this->getLanguageService()->sL('LLL:EXT:backend/Resources/Private/Language/locallang_column_selector.xlf:updateColumnView.nothingUpdated')
),
]);
}
$backendUser = $this->getBackendUserAuthentication();
$displayFields = $backendUser->getModuleData('list/displayFields');
$displayFields[$table] = $selectedColumns;
$backendUser->pushModuleData('list/displayFields', $displayFields);
return $this->jsonResponse(['success' => true]);
}
/**
* Generate the show columns selector form
*/
public function showColumnsSelectorAction(ServerRequestInterface $request): ResponseInterface
{
$queryParams = $request->getQueryParams();
$table = (string)($queryParams['table'] ?? '');
if ($table === '') {
throw new \RuntimeException('No table was given for selecting columns', 1625169125);
}
$view = $this->backendViewFactory->create($request);
$view->assignMultiple([
'table' => $table,
'columns' => $this->getColumns($table, (int)($queryParams['id'] ?? 0)),
]);
return $this->htmlResponse($view);
}
/**
* Retrieve all columns for the table, which can be selected
*/
protected function getColumns(string $table, int $pageId): array
{
$tsConfig = BackendUtility::getPagesTSconfig($pageId);
// Current fields selection
$displayFields = $this->getBackendUserAuthentication()->getModuleData('list/displayFields')[$table] ?? [];
if ($table === '_FILE') {
// Special handling for _FILE (merging sys_file and sys_file_metadata together)
$fields = $this->getFileFields();
} else {
// Request fields from table and add pseudo fields
$fields = array_merge(BackendUtility::getAllowedFieldsForTable($table), self::PSEUDO_FIELDS);
}
$columns = $specialColumns = $disabledColumns = [];
foreach ($fields as $fieldName) {
$concreteTableName = $table;
// In case we deal with _FILE, the field name is prefixed with the
// concrete table name, which is either sys_file or sys_file_metadata.
if ($table === '_FILE') {
[$concreteTableName, $fieldName] = explode('|', $fieldName);
}
// Hide field if disabled
if ($tsConfig['TCEFORM.'][$concreteTableName . '.'][$fieldName . '.']['disabled'] ?? false) {
continue;
}
$schema = $this->tcaSchemaFactory->get($concreteTableName);
$labelFieldName = false;
if ($schema->hasCapability(TcaSchemaCapability::Label)) {
$labelFieldName = $schema->getCapability(TcaSchemaCapability::Label)->getPrimaryFieldName();
}
// Determine if the column should be disabled (Meaning it is always selected and can not be turned off)
$isDisabled = $fieldName === $labelFieldName;
// Determine field label
$label = ($schema->hasField($fieldName) ? $schema->getField($fieldName)->getLabel() : '') ?: null;
$label = $this->getLanguageService()->translateLabel(
$tsConfig['TCEFORM.'][$concreteTableName . '.'][$fieldName . '.']['label.'] ?? [],
$tsConfig['TCEFORM.'][$concreteTableName . '.'][$fieldName . '.']['label']
?? $label
?? 'LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.' . $fieldName
);
// Add configuration for this column
$columnConfiguration = [
'name' => $fieldName,
'selected' => $isDisabled || in_array($fieldName, $displayFields, true),
'disabled' => $isDisabled,
'pseudo' => in_array($fieldName, self::PSEUDO_FIELDS, true),
'label' => $label,
];
// Add column configuration to the correct group
if ($columnConfiguration['disabled']) {
$disabledColumns[] = $columnConfiguration;
} elseif (!$columnConfiguration['label']) {
$specialColumns[] = $columnConfiguration;
} else {
$columns[] = $columnConfiguration;
}
}
// Sort standard columns by their resolved label
usort($columns, static fn($a, $b) => $a['label'] <=> $b['label']);
// Disabled columns go first, followed by standard columns
// and special columns, which do not have a label.
return array_merge($disabledColumns, $columns, $specialColumns);
}
/**
* Get file related fields by merging sys_file and sys_file_metadata together
* and adding the corresponding table as prefix (needed for labels processing).
*/
protected function getFileFields(): array
{
// Get all sys_file fields expect excluded ones
$fileFields = array_filter(
BackendUtility::getAllowedFieldsForTable('sys_file'),
static fn(string $field): bool => !in_array($field, self::EXCLUDE_FILE_FIELDS, true)
);
// Always add crdate and tstamp fields for files
$fileFields = array_unique(array_merge($fileFields, ['crdate', 'tstamp']));
// Update the exclude fields with the fields, already added through sys_file, since those take precedence
$excludeFields = array_merge($fileFields, self::EXCLUDE_FILE_FIELDS);
// Get all sys_file_metadata fields expect excluded ones
$fileMetaDataFields = array_filter(
BackendUtility::getAllowedFieldsForTable('sys_file_metadata'),
static fn(string $field): bool => !in_array($field, $excludeFields, true)
);
// Merge sys_file and sys_file_metadata fields together, while adding the table name as prefix
return array_merge(
array_map(static fn(string $value): string => 'sys_file|' . $value, $fileFields),
array_map(static fn(string $value): string => 'sys_file_metadata|' . $value, $fileMetaDataFields),
);
}
protected function htmlResponse(ViewInterface $view): ResponseInterface
{
$response = $this->responseFactory
->createResponse()
->withHeader('Content-Type', 'text/html; charset=utf-8');
$response->getBody()->write($view->render('ColumnSelector'));
return $response;
}
protected function jsonResponse(array $data): ResponseInterface
{
$response = $this->responseFactory
->createResponse()
->withAddedHeader('Content-Type', 'application/json; charset=utf-8');
$response->getBody()->write(json_encode($data));
return $response;
}
protected function getBackendUserAuthentication(): BackendUserAuthentication
{
return $GLOBALS['BE_USER'];
}
protected function getLanguageService(): LanguageService
{
return $GLOBALS['LANG'];
}
}
@@ -0,0 +1,554 @@
<?php
declare(strict_types=1);
/*
* 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 TYPO3\CMS\Backend\Controller\ContentElement;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Backend\Attribute\AsController;
use TYPO3\CMS\Backend\History\RecordHistory;
use TYPO3\CMS\Backend\History\RecordHistoryRollback;
use TYPO3\CMS\Backend\Routing\UriBuilder;
use TYPO3\CMS\Backend\Template\Components\ButtonBar;
use TYPO3\CMS\Backend\Template\Components\ComponentFactory;
use TYPO3\CMS\Backend\Template\ModuleTemplate;
use TYPO3\CMS\Backend\Template\ModuleTemplateFactory;
use TYPO3\CMS\Backend\Utility\BackendUtility;
use TYPO3\CMS\Backend\View\ValueFormatter\FlexFormValueFormatter;
use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
use TYPO3\CMS\Core\DataHandling\History\RecordHistoryStore;
use TYPO3\CMS\Core\DataHandling\TableColumnType;
use TYPO3\CMS\Core\Domain\DateTimeFactory;
use TYPO3\CMS\Core\Exception\SiteNotFoundException;
use TYPO3\CMS\Core\Imaging\IconFactory;
use TYPO3\CMS\Core\Imaging\IconSize;
use TYPO3\CMS\Core\Localization\LanguageService;
use TYPO3\CMS\Core\Schema\Capability\TcaSchemaCapability;
use TYPO3\CMS\Core\Schema\TcaSchemaFactory;
use TYPO3\CMS\Core\Site\Entity\SiteLanguage;
use TYPO3\CMS\Core\Site\SiteFinder;
use TYPO3\CMS\Core\Type\Bitmask\Permission;
use TYPO3\CMS\Core\Utility\DiffGranularity;
use TYPO3\CMS\Core\Utility\DiffUtility;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Controller for showing the history module of TYPO3s backend.
*
* @internal This class is a specific Backend controller implementation and is not considered part of the Public TYPO3 API.
*/
#[AsController]
class ElementHistoryController
{
protected RecordHistory $historyObject;
/**
* Display inline differences or not
*/
protected bool $showDiff = true;
protected array $recordCache = [];
protected ModuleTemplate $view;
protected string $returnUrl = '';
public function __construct(
protected readonly IconFactory $iconFactory,
protected readonly UriBuilder $uriBuilder,
protected readonly ModuleTemplateFactory $moduleTemplateFactory,
private readonly DiffUtility $diffUtility,
private readonly FlexFormValueFormatter $flexFormValueFormatter,
private readonly TcaSchemaFactory $tcaSchemaFactory,
private readonly ComponentFactory $componentFactory,
private readonly SiteFinder $siteFinder,
) {}
/**
* Injects the request object for the current request or sub request
* As this controller goes only through the main() method, it is rather simple for now
*/
public function mainAction(ServerRequestInterface $request): ResponseInterface
{
$this->view = $this->moduleTemplateFactory->create($request);
$backendUser = $this->getBackendUser();
$this->view->getDocHeaderComponent()->setPageBreadcrumb([]);
$parsedBody = $request->getParsedBody();
$queryParams = $request->getQueryParams();
$this->returnUrl = GeneralUtility::sanitizeLocalUrl($parsedBody['returnUrl'] ?? $queryParams['returnUrl'] ?? '', $request);
$lastHistoryEntry = (int)($parsedBody['historyEntry'] ?? $queryParams['historyEntry'] ?? 0);
$rollbackFields = $parsedBody['rollbackFields'] ?? $queryParams['rollbackFields'] ?? null;
$element = $parsedBody['element'] ?? $queryParams['element'] ?? null;
$moduleSettings = $this->processSettings($request);
$this->view->assign('isUserInWorkspace', $backendUser->workspace > 0);
$this->showDiff = (bool)$moduleSettings['showDiff'];
// Start history object
$this->historyObject = GeneralUtility::makeInstance(RecordHistory::class, $element);
$this->historyObject->setShowSubElements((bool)$moduleSettings['showSubElements']);
$this->historyObject->setLastHistoryEntryNumber($lastHistoryEntry);
if ($moduleSettings['maxSteps']) {
$this->historyObject->setMaxSteps((int)$moduleSettings['maxSteps']);
}
// Do the actual logic now (rollback, show a diff for certain changes,
// or show the full history of a page or a specific record)
$changeLog = $this->historyObject->getChangeLog();
if (!empty($changeLog)) {
if ($rollbackFields !== null) {
$diff = $this->historyObject->getDiff($changeLog);
GeneralUtility::makeInstance(RecordHistoryRollback::class)->performRollback($rollbackFields, $diff);
} elseif ($lastHistoryEntry) {
$completeDiff = $this->historyObject->getDiff($changeLog);
$this->displayMultipleDiff($completeDiff);
$button = $this->componentFactory->createLinkButton()
->setHref($this->buildUrl(['historyEntry' => '']))
->setIcon($this->iconFactory->getIcon('actions-view-go-back', IconSize::SMALL))
->setTitle($this->getLanguageService()->sL('LLL:EXT:backend/Resources/Private/Language/locallang_show_rechis.xlf:fullView'))
->setShowLabelText(true);
$this->view->addButtonToButtonBar($button);
}
if ($this->historyObject->getElementString() !== '') {
$this->displayHistory($changeLog);
}
}
$elementData = $this->historyObject->getElementInformation();
$editLock = false;
if (!empty($elementData)) {
[$elementTable, $elementUid] = $elementData;
$elementUid = (int)$elementUid;
$this->setPagePath($elementTable, $elementUid);
$editLock = $this->getEditLockFromElement($elementTable, $elementUid);
// Get link to page history if the element history is shown
if ($elementTable !== 'pages') {
$parentPage = BackendUtility::getRecord($elementTable, $elementUid, '*', '', false);
if ($parentPage['pid'] > 0 && BackendUtility::readPageAccess($parentPage['pid'], $backendUser->getPagePermsClause(Permission::PAGE_SHOW))) {
$button = $this->componentFactory->createLinkButton()
->setHref($this->buildUrl([
'element' => 'pages:' . $parentPage['pid'],
'historyEntry' => '',
]))
->setIcon($this->iconFactory->getIcon('apps-pagetree-page-default', IconSize::SMALL))
->setTitle($this->getLanguageService()->sL('LLL:EXT:backend/Resources/Private/Language/locallang_show_rechis.xlf:elementHistory_link'))
->setShowLabelText(true);
$this->view->addButtonToButtonBar($button, ButtonBar::BUTTON_POSITION_LEFT, 2);
}
}
}
if ($element !== null) {
$this->addLanguageSwitcher($request, $backendUser, $element);
}
$this->view->assign('editLock', $editLock);
$this->view->assign('moduleSettings', $moduleSettings);
$this->view->assign('settingsFormUrl', $this->buildUrl());
// Setting up the buttons and markers for docheader
$this->getButtons();
return $this->view->renderResponse('RecordHistory/Main');
}
/**
* Creates the correct path to the current record
*/
protected function setPagePath(string $table, int $uid): void
{
$record = BackendUtility::getRecord($table, $uid, '*', '', false);
if ($table === 'pages') {
$pageId = $uid;
} else {
$pageId = $record['pid'];
}
$pageAccess = BackendUtility::readPageAccess($pageId, $this->getBackendUser()->getPagePermsClause(Permission::PAGE_SHOW));
if (is_array($pageAccess)) {
$this->view->getDocHeaderComponent()->setPageBreadcrumb($pageAccess);
}
$schema = $this->tcaSchemaFactory->get($table);
$this->view->assignMultiple([
'recordTable' => $table,
'recordTableReadable' => $schema->getTitle($this->getLanguageService()->sL(...)),
'recordUid' => $uid,
'recordTitle' => $this->generateTitle($table, (string)$uid),
]);
}
protected function getButtons(): void
{
if ($this->returnUrl) {
$backButton = $this->componentFactory->createLinkButton()
->setHref($this->returnUrl)
->setTitle($this->getLanguageService()->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:rm.closeDoc'))
->setShowLabelText(true)
->setIcon($this->iconFactory->getIcon('actions-close', IconSize::SMALL));
$this->view->addButtonToButtonBar($backButton);
}
}
protected function processSettings(ServerRequestInterface $request): array
{
// Get current selection from UC, merge data, write it back to UC
$currentSelection = $this->getBackendUser()->getModuleData('history');
if (!is_array($currentSelection)) {
$currentSelection = ['maxSteps' => '', 'showDiff' => 1, 'showSubElements' => 1];
}
$currentSelectionOverride = $request->getParsedBody()['settings'] ?? null;
if (is_array($currentSelectionOverride) && !empty($currentSelectionOverride)) {
$currentSelection = array_merge($currentSelection, $currentSelectionOverride);
$this->getBackendUser()->pushModuleData('history', $currentSelection);
}
return $currentSelection;
}
/**
* Add a translation selection dropdown if the record is language aware.
*/
protected function addLanguageSwitcher(
ServerRequestInterface $request,
BackendUserAuthentication $backendUser,
string $element,
): void {
$translations = $this->historyObject->getTranslations($element);
if ($translations === null) {
return;
}
$languageDropDownButton = $this->componentFactory->createDropDownButton()
->setLabel($this->getLanguageService()->sL('core.core:labels.language'))
->setShowLabelText(true);
try {
$site = $this->siteFinder->getSiteByPageId($translations['page']);
} catch (SiteNotFoundException) {
$site = $request->getAttribute('site');
}
$availableLanguages = $site->getAvailableLanguages($backendUser, false, $translations['page']);
foreach ($translations['elements'] as $translation) {
$siteLanguage = $availableLanguages[$translation['language']] ?? null;
if (!$siteLanguage instanceof SiteLanguage) {
continue;
}
$languageItem = $this->componentFactory->createDropDownRadio()
->setActive($translation['element'] === $element)
->setIcon($this->iconFactory->getIcon($siteLanguage->getFlagIdentifier()))
->setHref((string)$this->uriBuilder->buildUriFromRoute('record_history', [
'element' => $translation['element'],
'returnUrl' => $this->returnUrl,
]))
->setLabel($siteLanguage->getTitle());
$languageDropDownButton->addItem($languageItem);
if ($languageItem->isActive()) {
$languageDropDownButton->setLabel($siteLanguage->getTitle());
}
}
$this->view->getDocHeaderComponent()->setLanguageSelector($languageDropDownButton);
}
/**
* Displays a diff over multiple fields including rollback links
*
* @param array $diff Difference array
*/
protected function displayMultipleDiff(array $diff): void
{
$languageService = $this->getLanguageService();
// Get all array keys needed
/** @var string[] $arrayKeys */
$arrayKeys = array_merge(array_keys($diff['newData']), array_keys($diff['insertsDeletes']), array_keys($diff['oldData']));
$arrayKeys = array_unique($arrayKeys);
if (!empty($arrayKeys)) {
$lines = [];
foreach ($arrayKeys as $key) {
$singleLine = [];
$elParts = explode(':', $key);
// Turn around diff because it should be a "rollback preview"
if ((int)($diff['insertsDeletes'][$key] ?? 0) === 1) {
// insert
$singleLine['insertDelete'] = 'delete';
} elseif ((int)($diff['insertsDeletes'][$key] ?? 0) === -1) {
$singleLine['insertDelete'] = 'insert';
}
// Build up temporary diff array
// turn around diff because it should be a "rollback preview"
if ($diff['newData'][$key] ?? false) {
$tmpArr = [
'newRecord' => $diff['oldData'][$key],
'oldRecord' => $diff['newData'][$key],
];
// show changes
if (!$this->showDiff) {
// Display field names instead of full diff
// Re-write field names with labels
/** @var string[] $tmpFieldList */
$tmpFieldList = array_keys($tmpArr['newRecord']);
foreach ($tmpFieldList as $fieldKey => $value) {
$itemLabel = '';
if ($this->tcaSchemaFactory->has($elParts[0]) && ($schema = $this->tcaSchemaFactory->get($elParts[0]))->hasField($value)) {
$itemLabel = $schema->getField($value)->getLabel();
}
$tmp = str_replace(':', '', $languageService->sL($itemLabel));
if ($tmp) {
$tmpFieldList[$fieldKey] = $tmp;
} else {
// remove fields if no label available
unset($tmpFieldList[$fieldKey]);
}
}
$singleLine['fieldNames'] = implode(',', $tmpFieldList);
} else {
// Display diff
$singleLine['differences'] = $this->renderDiff($tmpArr, $elParts[0], (int)$elParts[1], true);
}
}
$elParts = explode(':', $key);
$singleLine['revertRecordUrl'] = $this->buildUrl(['rollbackFields' => $key]);
$singleLine['title'] = $this->generateTitle($elParts[0], $elParts[1]);
$singleLine['recordTable'] = $elParts[0];
$singleLine['recordUid'] = $elParts[1];
$lines[] = $singleLine;
}
$this->view->assign('revertAllUrl', $this->buildUrl(['rollbackFields' => 'ALL']));
$this->view->assign('multipleDiff', $lines);
}
$this->view->assign('showDifferences', true);
}
/**
* Shows the full change log
*/
protected function displayHistory(array $historyEntries): void
{
if ($historyEntries === []) {
return;
}
$languageService = $this->getLanguageService();
$lines = [];
$beUserArray = BackendUtility::getUserNames('username,realName,usergroup,uid');
// Traverse changeLog array:
foreach ($historyEntries as $entry) {
// Build up single line
$singleLine = [];
// Get user names
$singleLine['backendUserUid'] = $entry['userid'];
$singleLine['backendUserName'] = $beUserArray[$entry['userid']]['username'] ?? '';
$singleLine['backendUserRealName'] = $beUserArray[$entry['userid']]['realName'] ?? '';
// Executed by switch user
if (!empty($entry['originaluserid'])) {
$singleLine['originalBackendUserUid'] = $entry['originaluserid'];
$singleLine['originalBackendUserName'] = $beUserArray[$entry['originaluserid']]['username'] ?? '';
$singleLine['originalBackendRealName'] = $beUserArray[$entry['originaluserid']]['realName'] ?? '';
}
// Is a change in a workspace?
$singleLine['isChangedInWorkspace'] = (int)$entry['workspace'] > 0;
// Diff link
$singleLine['diffUrl'] = $this->buildUrl(['historyEntry' => $entry['uid']]);
// Add time
$singleLine['day'] = BackendUtility::date($entry['tstamp']);
$singleLine['timestamp'] = DateTimeFactory::createFromTimestamp($entry['tstamp']);
$singleLine['title'] = $this->generateTitle($entry['tablename'], (string)$entry['recuid']);
$singleLine['recordTable'] = $entry['tablename'];
$singleLine['recordUid'] = $entry['recuid'];
$singleLine['elementUrl'] = $this->buildUrl(['element' => $entry['tablename'] . ':' . $entry['recuid']]);
$singleLine['actiontype'] = $entry['actiontype'];
if ((int)$entry['actiontype'] === RecordHistoryStore::ACTION_MODIFY || (int)$entry['actiontype'] === RecordHistoryStore::ACTION_PUBLISH) {
// show changes
if (!$this->showDiff) {
// Display field names instead of full diff
// Re-write field names with labels
/** @var string[] $tmpFieldList */
$tmpFieldList = array_keys($entry['newRecord']);
foreach ($tmpFieldList as $key => $value) {
$itemLabel = '';
if ($this->tcaSchemaFactory->has($entry['tablename']) && ($schema = $this->tcaSchemaFactory->get($entry['tablename']))->hasField($value)) {
$itemLabel = $schema->getField($value)->getLabel();
}
$tmp = str_replace(':', '', $languageService->sL($itemLabel));
if ($tmp) {
$tmpFieldList[$key] = $tmp;
} else {
// remove fields if no label available
unset($tmpFieldList[$key]);
}
}
$singleLine['fieldNames'] = implode(',', $tmpFieldList);
} else {
// Display diff
$singleLine['differences'] = $this->renderDiff($entry, $entry['tablename'], (int)$entry['recuid']);
}
}
// put line together
$lines[] = $singleLine;
}
$this->view->assign('history', $lines);
}
/**
* Renders HTML table-rows with the comparison information of a sys_history entry record
*
* @param array $entry sys_history entry record.
* @param string $table The table name
* @param int $rollbackUid The UID of the record
* @param bool $showRollbackLink Whether a rollback link should be shown for each changed field
* @return array array of records
*/
protected function renderDiff(array $entry, string $table, int $rollbackUid, bool $showRollbackLink = false): array
{
if (!$this->tcaSchemaFactory->has($table)) {
return [];
}
$lines = [];
if (is_array($entry['newRecord'] ?? null)) {
$fieldsToDisplay = array_keys($entry['newRecord']);
$languageService = $this->getLanguageService();
$schema = $this->tcaSchemaFactory->get($table);
foreach ($fieldsToDisplay as $fN) {
if (!$schema->hasField($fN)) {
continue;
}
$fieldInformation = $schema->getField($fN);
if (!$fieldInformation->isType(TableColumnType::PASSTHROUGH)) {
if ($fieldInformation->isType(TableColumnType::FLEX)) {
$colConfig = $fieldInformation->getConfiguration();
$old = $this->flexFormValueFormatter->format($table, $fN, ($entry['oldRecord'][$fN] ?? ''), $rollbackUid, $colConfig);
$new = $this->flexFormValueFormatter->format($table, $fN, ($entry['newRecord'][$fN] ?? ''), $rollbackUid, $colConfig);
$diffResult = $this->diffUtility->diff(strip_tags($old), strip_tags($new), DiffGranularity::CHARACTER);
} else {
$old = (string)BackendUtility::getProcessedValue($table, $fN, ($entry['oldRecord'][$fN] ?? ''), 0, true, false, $rollbackUid);
$new = (string)BackendUtility::getProcessedValue($table, $fN, ($entry['newRecord'][$fN] ?? ''), 0, true, false, $rollbackUid);
$diffResult = $this->diffUtility->diff(strip_tags($old), strip_tags($new));
}
$rollbackUrl = '';
if ($rollbackUid && $showRollbackLink) {
$rollbackUrl = $this->buildUrl(['rollbackFields' => $table . ':' . $rollbackUid . ':' . $fN]);
}
$lines[] = [
'title' => $languageService->sL($fieldInformation->getLabel()),
'rollbackUrl' => $rollbackUrl,
'result' => str_replace('\n', PHP_EOL, str_replace('\r\n', '\n', $diffResult)),
];
}
}
}
return $lines;
}
/**
* Generates the URL for a link to the current page
*/
protected function buildUrl(array $overrideParameters = []): string
{
$params = [];
// Setting default values based on GET parameters:
$elementString = $this->historyObject->getElementString();
if ($elementString !== '') {
$params['element'] = $elementString;
}
$params['historyEntry'] = $this->historyObject->getLastHistoryEntryNumber();
if (!empty($this->returnUrl)) {
$params['returnUrl'] = $this->returnUrl;
}
// Merging overriding values:
$params = array_merge($params, $overrideParameters);
// Make the link:
return (string)$this->uriBuilder->buildUriFromRoute('record_history', $params);
}
/**
* Generates the title and puts the record title behind
*/
protected function generateTitle(string $table, string $uid): string
{
if ($this->tcaSchemaFactory->get($table)->hasCapability(TcaSchemaCapability::Label)) {
$record = $this->getRecord($table, (int)$uid) ?? [];
return BackendUtility::getRecordTitle($table, $record);
}
return '';
}
/**
* Gets a database record (cached).
*/
protected function getRecord(string $table, int $uid): ?array
{
if (!isset($this->recordCache[$table][$uid])) {
$this->recordCache[$table][$uid] = BackendUtility::getRecord($table, $uid, '*', '', false);
}
return $this->recordCache[$table][$uid];
}
protected function getLanguageService(): LanguageService
{
return $GLOBALS['LANG'];
}
protected function getBackendUser(): BackendUserAuthentication
{
return $GLOBALS['BE_USER'];
}
/**
* Get the editlock value from page of a history element
*/
protected function getEditLockFromElement(string $tableName, int $elementUid): bool
{
// If the user is admin, then he may always edit the page.
if ($this->getBackendUser()->isAdmin()) {
return false;
}
$schema = $this->tcaSchemaFactory->get($tableName);
// Early return if $elementUid is zero
if ($elementUid === 0) {
return !$schema->getCapability(TcaSchemaCapability::RestrictionRootLevel)->shallIgnoreRootLevelRestriction();
}
$record = BackendUtility::getRecord($tableName, $elementUid, '*', '', false);
// we need the parent page record for the editlock info if element isn't a page
if ($tableName !== 'pages') {
$pageId = $record['pid'];
$record = BackendUtility::getRecord('pages', $pageId, '*', '', false);
}
return $schema->hasCapability(TcaSchemaCapability::EditLock)
&& ($record[$schema->getCapability(TcaSchemaCapability::EditLock)->getFieldName()] ?? false);
}
}
@@ -0,0 +1,797 @@
<?php
declare(strict_types=1);
/*
* 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 TYPO3\CMS\Backend\Controller\ContentElement;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Backend\Attribute\AsController;
use TYPO3\CMS\Backend\History\RecordHistory;
use TYPO3\CMS\Backend\Routing\Exception\RouteNotFoundException;
use TYPO3\CMS\Backend\Routing\PreviewUriBuilder;
use TYPO3\CMS\Backend\Routing\UriBuilder;
use TYPO3\CMS\Backend\Template\ModuleTemplateFactory;
use TYPO3\CMS\Backend\Utility\BackendUtility;
use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
use TYPO3\CMS\Core\Database\Connection;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Imaging\IconFactory;
use TYPO3\CMS\Core\Imaging\IconSize;
use TYPO3\CMS\Core\Localization\LanguageService;
use TYPO3\CMS\Core\Resource\File;
use TYPO3\CMS\Core\Resource\FileType;
use TYPO3\CMS\Core\Resource\Folder;
use TYPO3\CMS\Core\Resource\Index\MetaDataRepository;
use TYPO3\CMS\Core\Resource\Rendering\RendererRegistry;
use TYPO3\CMS\Core\Resource\ResourceFactory;
use TYPO3\CMS\Core\Schema\Capability\TcaSchemaCapability;
use TYPO3\CMS\Core\Schema\SearchableSchemaFieldsCollector;
use TYPO3\CMS\Core\Schema\TcaSchema;
use TYPO3\CMS\Core\Schema\TcaSchemaFactory;
use TYPO3\CMS\Core\Schema\VisibleSchemaFieldsCollector;
use TYPO3\CMS\Core\Type\Bitmask\Permission;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Modal rendering detail about a record. Reached by "Display information" on click menu and records module.
*
* @internal This class is a specific Backend controller implementation and is not considered part of the Public TYPO3 API.
*/
#[AsController]
class ElementInformationController
{
/**
* Type of element: "db", "file" or "folder"
*/
protected string $type = 'db';
protected array $row = [];
protected ?string $table = null;
protected ?File $fileObject = null;
protected ?Folder $folderObject = null;
public function __construct(
protected readonly IconFactory $iconFactory,
protected readonly UriBuilder $uriBuilder,
protected readonly ModuleTemplateFactory $moduleTemplateFactory,
protected readonly ResourceFactory $resourceFactory,
protected readonly TcaSchemaFactory $tcaSchemaFactory,
protected readonly VisibleSchemaFieldsCollector $visibleSchemaFieldsCollector,
private readonly SearchableSchemaFieldsCollector $searchableSchemaFieldsCollector,
private readonly MetaDataRepository $metaDataRepository,
private readonly ConnectionPool $connectionPool,
private readonly RendererRegistry $rendererRegistry,
) {}
/**
* Injects the request object for the current request or subrequest
* As this controller goes only through the main() method, it is rather simple for now
*/
public function mainAction(ServerRequestInterface $request): ResponseInterface
{
$backendUser = $this->getBackendUser();
$view = $this->moduleTemplateFactory->create($request);
$view->getDocHeaderComponent()->disable();
$queryParams = $request->getQueryParams();
$this->table = $queryParams['table'] ?? null;
$uid = $queryParams['uid'] ?? '';
$permsClause = $backendUser->getPagePermsClause(Permission::PAGE_SHOW);
// Determines if table/uid point to database record or file and if user has access to view information
$accessAllowed = false;
if ($this->tcaSchemaFactory->has($this->table)) {
$uid = (int)$uid;
// Check permissions and uid value:
if ($uid && $backendUser->check('tables_select', $this->table)) {
if ((string)$this->table === 'pages') {
$this->row = BackendUtility::readPageAccess($uid, $permsClause) ?: [];
$accessAllowed = $this->row !== [];
} else {
$this->row = BackendUtility::getRecordWSOL($this->table, $uid);
if ($this->row) {
if (isset($this->row['_ORIG_uid'])) {
// Make $uid the uid of the versioned record, while $this->row['uid'] is live record uid
$uid = (int)$this->row['_ORIG_uid'];
}
$pageInfo = BackendUtility::readPageAccess((int)$this->row['pid'], $permsClause) ?: [];
$accessAllowed = $pageInfo !== []
|| ((int)$this->row['pid'] === 0 && $this->tcaSchemaFactory->get($this->table)->getCapability(TcaSchemaCapability::RestrictionRootLevel)->shallIgnoreRootLevelRestriction());
}
}
}
} elseif ($this->table === '_FILE' || $this->table === '_FOLDER' || $this->table === 'sys_file') {
$fileOrFolderObject = $this->resourceFactory->retrieveFileOrFolderObject($uid);
if ($fileOrFolderObject instanceof Folder) {
$this->folderObject = $fileOrFolderObject;
$accessAllowed = $this->folderObject->checkActionPermission('read');
$this->type = 'folder';
} elseif ($fileOrFolderObject instanceof File) {
$this->fileObject = $fileOrFolderObject;
$accessAllowed = $this->fileObject->checkActionPermission('read');
$this->type = 'file';
$this->table = 'sys_file';
$this->row = BackendUtility::getRecordWSOL($this->table, $fileOrFolderObject->getUid());
}
}
// Rendering of the output via fluid
$view->assign('accessAllowed', $accessAllowed);
$view->assign('hookContent', '');
if (!$accessAllowed) {
return $view->renderResponse('ContentElement/ElementInformation');
}
// render type by user func
foreach ($GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['typo3/show_item.php']['typeRendering'] ?? [] as $className) {
$typeRenderObj = GeneralUtility::makeInstance($className);
if (method_exists($typeRenderObj, 'isValid') && method_exists($typeRenderObj, 'render')) {
if ($typeRenderObj->isValid($this->type, $this)) {
$view->assign('hookContent', $typeRenderObj->render($this->type, $this, $view));
return $view->renderResponse('ContentElement/ElementInformation');
}
}
}
$pageTitle = $this->getPageTitle();
$view->setTitle($pageTitle['table'] . ': ' . $pageTitle['title']);
$view->assignMultiple($pageTitle);
$view->assignMultiple($this->getPreview($request));
$view->assignMultiple($this->getPropertiesForTable());
$view->assignMultiple($this->getReferences($request, $uid));
$view->assign('returnUrl', GeneralUtility::sanitizeLocalUrl($request->getQueryParams()['returnUrl'] ?? '', $request));
$view->assign('maxTitleLength', $this->getBackendUser()->uc['titleLen'] ?? 20);
return $view->renderResponse('ContentElement/ElementInformation');
}
/**
* Get page title with icon, table title and record title
*/
public function getPageTitle(): array
{
$pageTitle = [
'title' => BackendUtility::getRecordTitle($this->table, $this->row),
];
if ($this->type === 'folder') {
$pageTitle['title'] = htmlspecialchars($this->folderObject->getName());
$pageTitle['table'] = $this->getLanguageService()->sL('LLL:EXT:core/Resources/Private/Language/locallang_common.xlf:folder');
$pageTitle['icon'] = $this->iconFactory->getIconForResource($this->folderObject, IconSize::SMALL)->render();
} elseif ($this->type === 'file') {
$schema = $this->tcaSchemaFactory->get($this->table);
$pageTitle['table'] = $schema->getTitle($this->getLanguageService()->sL(...));
$pageTitle['icon'] = $this->iconFactory->getIconForResource($this->fileObject, IconSize::SMALL)->render();
} else {
$schema = $this->tcaSchemaFactory->get($this->table);
$pageTitle['table'] = $schema->getTitle($this->getLanguageService()->sL(...));
$pageTitle['icon'] = $this->iconFactory->getIconForRecord($this->table, $this->row, IconSize::SMALL);
}
return $pageTitle;
}
public function getTable(): ?string
{
return $this->table;
}
public function getRow(): array
{
return $this->row;
}
public function getFileObject(): ?File
{
return $this->fileObject;
}
public function getFolderObject(): ?Folder
{
return $this->folderObject;
}
/**
* Get preview for current record
*/
protected function getPreview(ServerRequestInterface $request): array
{
$preview = [];
// Perhaps @todo in future: Also display preview for records - without fileObject
if (!$this->fileObject) {
return $preview;
}
// check if file is marked as missing
if ($this->fileObject->isMissing()) {
$preview['missingFile'] = $this->fileObject->getName();
} else {
$fileRenderer = $this->rendererRegistry->getRenderer($this->fileObject);
$preview['url'] = $this->fileObject->getPublicUrl() ?? '';
// Add "edit metadata" button
$preview['editMetadataUrl'] = '';
if (($metaDataUid = $this->fileObject->getProperties()['metadata_uid'] ?? false)
&& $this->fileObject->isIndexed()
&& $this->fileObject->checkActionPermission('editMeta')
&& $this->getBackendUser()->check('tables_modify', 'sys_file_metadata')
) {
$urlParameters = [
'edit' => [
'sys_file_metadata' => [
$metaDataUid => 'edit',
],
],
'returnUrl' => $request->getAttribute('normalizedParams')->getRequestUri(),
];
$preview['editMetadataUrl'] = (string)$this->uriBuilder->buildUriFromRoute('record_edit', $urlParameters);
}
$width = min(590, $this->fileObject->getMetaData()['width'] ?? 590) . 'm';
$height = min(400, $this->fileObject->getMetaData()['height'] ?? 400) . 'm';
// Check if there is a FileRenderer
if ($fileRenderer !== null) {
$preview['fileRenderer'] = $fileRenderer->render($this->fileObject, $width, $height);
// else check if we can create an Image preview
} elseif ($this->fileObject->isImage()) {
$preview['fileObject'] = $this->fileObject;
$preview['width'] = $width;
$preview['height'] = $height;
}
}
return $preview;
}
/**
* Get property array for html table
*/
protected function getPropertiesForTable(): array
{
$lang = $this->getLanguageService();
$propertiesForTable = [];
$propertiesForTable['extraFields'] = $this->getExtraFields();
// Traverse the list of fields to display for the record:
$fieldList = $this->getFieldList($this->table, $this->row);
$schema = $this->tcaSchemaFactory->has($this->table) ? $this->tcaSchemaFactory->get($this->table) : null;
foreach ($fieldList as $name) {
$name = trim($name);
$uid = $this->row['uid'] ?? 0;
if (!$schema?->hasField($name)) {
continue;
}
// @todo Add meaningful information for mfa field. For the time being we don't display anything at all.
if ($this->type === 'db' && $name === 'mfa' && in_array($this->table, ['be_users', 'fe_users'], true)) {
continue;
}
// not a real field -> skip
if ($this->type === 'file' && $name === 'fileinfo') {
continue;
}
// handled explicitly below with proper byte formatting -> skip
if ($this->type === 'file' && $name === 'size') {
continue;
}
// Field does not exist (e.g. having type=none) -> skip
if (!array_key_exists($name, $this->row)) {
continue;
}
$label = $lang->sL($schema->getField($name)->getLabel());
$label = $label ?: $name;
$propertiesForTable['fields'][] = [
'fieldValue' => BackendUtility::getProcessedValue($this->table, $name, $this->row[$name], 0, false, false, $uid, true, 0, $this->row),
'fieldLabel' => htmlspecialchars($label),
];
}
// additional information for folders and files
if ($this->folderObject instanceof Folder || $this->fileObject instanceof File) {
// storage
if ($this->folderObject instanceof Folder) {
$propertiesForTable['fields']['storage'] = [
'fieldValue' => $this->folderObject->getStorage()->getName(),
'fieldLabel' => htmlspecialchars($lang->sL('LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:sys_file.storage')),
];
}
// folder
$resourceObject = $this->fileObject ?: $this->folderObject;
$parentFolder = $resourceObject->getParentFolder();
$propertiesForTable['fields']['folder'] = [
'fieldValue' => $parentFolder->getReadablePath(),
'fieldLabel' => htmlspecialchars($lang->sL('LLL:EXT:core/Resources/Private/Language/locallang_common.xlf:folder')),
];
if ($this->fileObject instanceof File) {
// show file dimensions for images
if ($this->fileObject->isType(FileType::IMAGE)) {
$propertiesForTable['fields']['width'] = [
'fieldValue' => $this->fileObject->getProperty('width') . 'px',
'fieldLabel' => htmlspecialchars($lang->sL('LLL:EXT:core/Resources/Private/Language/locallang_general.xlf:LGL.width')),
];
$propertiesForTable['fields']['height'] = [
'fieldValue' => $this->fileObject->getProperty('height') . 'px',
'fieldLabel' => htmlspecialchars($lang->sL('LLL:EXT:core/Resources/Private/Language/locallang_general.xlf:LGL.height')),
];
}
// file size
$fileSizeInBytes = (int)$this->fileObject->getProperty('size');
$propertiesForTable['fields']['size'] = [
'fieldValue' => sprintf(
'%s (%s)',
GeneralUtility::formatSize($fileSizeInBytes, htmlspecialchars($this->getLanguageService()->sL('core.common:byteSizeUnits'))),
htmlspecialchars($lang->translate('size_in_bytes', 'core.core', ['numberOfBytes' => GeneralUtility::formatSize($fileSizeInBytes, ' ')])),
),
'fieldLabel' => $lang->sL($schema?->hasField('size') ? $schema->getField('size')->getLabel() : ''),
];
// show the metadata of a file as well
$metaData = $this->metaDataRepository->findByFileUid((int)($this->row['uid'] ?? 0));
// If there is no metadata record, skip it
if ($metaData !== []) {
$fileMetadataSchema = $this->tcaSchemaFactory->get('sys_file_metadata');
$allowedFields = $this->getFieldList('sys_file_metadata', $metaData);
foreach ($metaData as $name => $value) {
if (!in_array($name, $allowedFields, true)) {
continue;
}
if ($name === 'crdate') {
// Is of type=passthrough and already part of
// meta information displayed on top of the table
continue;
}
if (!$fileMetadataSchema->hasField($name)) {
continue;
}
$label = $lang->sL($fileMetadataSchema->getField($name)->getLabel());
$label = $label ?: $name;
$propertiesForTable['fields'][] = [
'fieldValue' => BackendUtility::getProcessedValue('sys_file_metadata', $name, $value, 0, false, false, (int)$metaData['uid'], true, 0, $metaData),
'fieldLabel' => htmlspecialchars($label),
];
}
}
}
}
return $propertiesForTable;
}
/**
* Get the list of fields that should be shown for the given table
*/
protected function getFieldList(string $table, array $row): array
{
$fieldNamesToExclude = [];
if ($this->tcaSchemaFactory->has($table)) {
$schema = $this->tcaSchemaFactory->get($table);
if ($schema->hasCapability(TcaSchemaCapability::AncestorReferenceField)) {
$fieldNamesToExclude[] = $schema->getCapability(TcaSchemaCapability::AncestorReferenceField)->getFieldName();
}
if ($schema->isLanguageAware()) {
$languageCapability = $schema->getCapability(TcaSchemaCapability::Language);
$fieldNamesToExclude[] = $languageCapability->getTranslationOriginPointerField()->getName();
if ($languageCapability->hasDiffSourceField()) {
$fieldNamesToExclude[] = $languageCapability->getDiffSourceField()?->getName();
}
}
}
return $this->searchableSchemaFieldsCollector->getUniqueFieldList(
$table,
$this->visibleSchemaFieldsCollector->getFieldNames($table, $row, $fieldNamesToExclude),
false
);
}
/**
* Get the extra fields (uid, timestamps, creator) for the table
*/
protected function getExtraFields(): array
{
$lang = $this->getLanguageService();
$keyLabelPair = [];
if (in_array($this->type, ['folder', 'file'], true)) {
if ($this->type === 'file') {
$keyLabelPair['uid'] = [
'value' => (int)$this->row['uid'],
];
$keyLabelPair['creation_date'] = [
'value' => BackendUtility::datetime($this->row['creation_date']),
'fieldLabel' => htmlspecialchars($lang->sL('LLL:EXT:core/Resources/Private/Language/locallang_general.xlf:LGL.creationDate')),
'isDatetime' => true,
];
$keyLabelPair['modification_date'] = [
'value' => BackendUtility::datetime($this->row['modification_date']),
'fieldLabel' => htmlspecialchars($lang->sL('LLL:EXT:core/Resources/Private/Language/locallang_general.xlf:LGL.timestamp')),
'isDatetime' => true,
];
} else {
$keyLabelPair['uid'] = [
'value' => $this->folderObject->getCombinedIdentifier(),
];
}
} else {
$keyLabelPair['uid'] = [
'value' => BackendUtility::getProcessedValueExtra($this->table, 'uid', $this->row['uid']),
'fieldLabel' => rtrim(htmlspecialchars($lang->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:show_item.php.uid')), ':'),
];
$schema = $this->tcaSchemaFactory->get($this->table);
if ($schema->hasCapability(TcaSchemaCapability::CreatedAt)) {
$field = $schema->getCapability(TcaSchemaCapability::CreatedAt)->getFieldName();
$keyLabelPair[$field] = [
'value' => BackendUtility::datetime($this->row[$field]),
'fieldLabel' => rtrim(htmlspecialchars($lang->sL('LLL:EXT:core/Resources/Private/Language/locallang_general.xlf:LGL.creationDate')), ':'),
'isDatetime' => true,
];
}
if ($schema->hasCapability(TcaSchemaCapability::UpdatedAt)) {
$field = $schema->getCapability(TcaSchemaCapability::UpdatedAt)->getFieldName();
$keyLabelPair[$field] = [
'value' => BackendUtility::datetime($this->row[$field]),
'fieldLabel' => rtrim(htmlspecialchars($lang->sL('LLL:EXT:core/Resources/Private/Language/locallang_general.xlf:LGL.timestamp')), ':'),
'isDatetime' => true,
];
}
// Show the user who created the record
$recordHistory = GeneralUtility::makeInstance(RecordHistory::class);
$ownerInformation = $recordHistory->getCreationInformationForRecord($this->table, $this->row);
$ownerUid = (int)(is_array($ownerInformation) && $ownerInformation['usertype'] === 'BE' ? $ownerInformation['userid'] : 0);
if ($ownerUid) {
$creatorRecord = BackendUtility::getRecord('be_users', $ownerUid);
if ($creatorRecord) {
$keyLabelPair['creatorRecord'] = [
'value' => $creatorRecord,
'fieldLabel' => rtrim(htmlspecialchars($lang->sL('LLL:EXT:core/Resources/Private/Language/locallang_general.xlf:LGL.creationUserId')), ':'),
];
}
}
}
return $keyLabelPair;
}
/**
* Get references section (references from and references to current record)
*/
protected function getReferences(ServerRequestInterface $request, int|string $uid): array
{
$references = [];
switch ($this->type) {
case 'db': {
$references['refLines'] = $this->makeRef($this->table, $uid, $request);
$references['refFromLines'] = $this->makeRefFrom($this->table, $uid, $request);
break;
}
case 'file': {
if ($this->fileObject && $this->fileObject->isIndexed()) {
$references['refLines'] = $this->makeRef('_FILE', $this->fileObject, $request);
}
break;
}
}
return $references;
}
/**
* Get field name for specified table/column name
*
* @param string $fieldName Column name
*/
protected function getLabelForTableColumn(TcaSchema $schema, string $fieldName): string
{
if ($schema->hasField($fieldName)) {
$field = $schema->getField($fieldName);
$field = $field->getLabel() ? $this->getLanguageService()->sL($field->getLabel()) : $fieldName;
if (trim($field) === '') {
$field = $fieldName;
}
} else {
$field = $fieldName;
}
return $field;
}
/**
* Returns the record actions
*
* @param int $uid
* @throws RouteNotFoundException
*/
protected function getRecordActions(TcaSchema $schema, $uid, ServerRequestInterface $request): array
{
if ($uid < 0) {
return [];
}
$actions = [];
// Edit button
$urlParameters = [
'edit' => [
$schema->getName() => [
$uid => 'edit',
],
],
'returnUrl' => $request->getAttribute('normalizedParams')->getRequestUri(),
];
$actions['recordEditUrl'] = (string)$this->uriBuilder->buildUriFromRoute('record_edit', $urlParameters);
// History button
$urlParameters = [
'element' => $schema->getName() . ':' . $uid,
'returnUrl' => $request->getAttribute('normalizedParams')->getRequestUri(),
];
$actions['recordHistoryUrl'] = (string)$this->uriBuilder->buildUriFromRoute('record_history', $urlParameters);
if ($schema->getName() === 'pages') {
// Recordlist button
$actions['recordsModuleUrl'] = (string)$this->uriBuilder->buildUriFromRoute('records', ['id' => $uid, 'returnUrl' => $request->getAttribute('normalizedParams')->getRequestUri()]);
// retrieve record to get page language
$record = BackendUtility::getRecord($schema->getName(), $uid);
$previewUriBuilder = PreviewUriBuilder::create($record)
->withRootLine(BackendUtility::BEgetRootLine($uid));
// View page button
$actions['previewUrlAttributes'] = $previewUriBuilder->serializeDispatcherAttributes();
}
return $actions;
}
/**
* Make reference display
*
* @param string $table Table name
* @param int|File $ref Filename or uid
* @throws RouteNotFoundException
*/
protected function makeRef(string $table, $ref, ServerRequestInterface $request): array
{
$refLines = [];
$lang = $this->getLanguageService();
// Files reside in sys_file table
if ($table === '_FILE') {
$selectTable = 'sys_file';
$selectUid = $ref->getUid();
} else {
$selectTable = $table;
$selectUid = $ref;
}
$queryBuilder = $this->connectionPool
->getQueryBuilderForTable('sys_refindex');
$predicates = [
$queryBuilder->expr()->eq(
'ref_table',
$queryBuilder->createNamedParameter($selectTable)
),
$queryBuilder->expr()->eq(
'ref_uid',
$queryBuilder->createNamedParameter($selectUid, Connection::PARAM_INT)
),
];
$backendUser = $this->getBackendUser();
if (!$backendUser->isAdmin()) {
$allowedSelectTables = GeneralUtility::trimExplode(',', $backendUser->groupData['tables_select']);
$predicates[] = $queryBuilder->expr()->in(
'tablename',
$queryBuilder->createNamedParameter($allowedSelectTables, Connection::PARAM_STR_ARRAY)
);
}
$rows = $queryBuilder
->select('*')
->from('sys_refindex')
->where(...$predicates)
->executeQuery()
->fetchAllAssociative();
// Compile information for title tag:
foreach ($rows as $row) {
if ($row['tablename'] === 'sys_file_reference') {
$row = $this->transformFileReferenceToRecordReference($row);
if ($row === null) {
continue;
}
}
if (!$this->tcaSchemaFactory->has($row['tablename'])) {
continue;
}
$schema = $this->tcaSchemaFactory->get($row['tablename']);
$line = [];
$record = BackendUtility::getRecordWSOL($row['tablename'], $row['recuid']);
if ($record) {
if (!$this->canAccessPage($schema, $record)) {
continue;
}
$parentRecord = BackendUtility::getRecord('pages', $record['pid']);
$parentRecordTitle = is_array($parentRecord)
? BackendUtility::getRecordTitle('pages', $parentRecord)
: '';
$urlParameters = [
'edit' => [
$row['tablename'] => [
$row['recuid'] => 'edit',
],
],
'returnUrl' => $request->getAttribute('normalizedParams')->getRequestUri(),
];
$url = (string)$this->uriBuilder->buildUriFromRoute('record_edit', $urlParameters);
$line['url'] = $url;
$line['icon'] = $this->iconFactory->getIconForRecord($row['tablename'], $record, IconSize::SMALL)->render();
$line['row'] = $row;
$line['record'] = $record;
$line['recordTitle'] = BackendUtility::getRecordTitle($row['tablename'], $record);
$line['parentRecord'] = $parentRecord;
$line['parentRecordTitle'] = $parentRecordTitle;
$line['title'] = $schema->getTitle($lang->sL(...)) ?: $row['tablename'];
$line['labelForTableColumn'] = $this->getLabelForTableColumn($schema, $row['field']);
$line['path'] = BackendUtility::getRecordPath($record['pid'], '', 0, 0);
$line['actions'] = $this->getRecordActions($schema, $row['recuid'], $request);
} else {
$line['row'] = $row;
$line['title'] = $schema->getTitle($lang->sL(...)) ?: $row['tablename'];
$line['labelForTableColumn'] = $this->getLabelForTableColumn($schema, $row['field']);
}
$refLines[] = $line;
}
return $refLines;
}
/**
* Make reference display (what this elements points to)
*
* @param string $table Table name
* @param int $ref Filename or uid
*/
protected function makeRefFrom($table, $ref, ServerRequestInterface $request): array
{
$refFromLines = [];
$lang = $this->getLanguageService();
$queryBuilder = $this->connectionPool
->getQueryBuilderForTable('sys_refindex');
$predicates = [
$queryBuilder->expr()->eq(
'tablename',
$queryBuilder->createNamedParameter($table)
),
$queryBuilder->expr()->eq(
'recuid',
$queryBuilder->createNamedParameter($ref, Connection::PARAM_INT)
),
];
$backendUser = $this->getBackendUser();
if (!$backendUser->isAdmin()) {
$allowedSelectTables = GeneralUtility::trimExplode(',', $backendUser->groupData['tables_select']);
$predicates[] = $queryBuilder->expr()->in(
'ref_table',
$queryBuilder->createNamedParameter($allowedSelectTables, Connection::PARAM_STR_ARRAY)
);
}
$rows = $queryBuilder
->select('*')
->from('sys_refindex')
->where(...$predicates)
->executeQuery()
->fetchAllAssociative();
// Compile information for title tag:
foreach ($rows as $row) {
$line = [];
$record = BackendUtility::getRecordWSOL($row['ref_table'], $row['ref_uid']);
if (!$this->tcaSchemaFactory->has($row['ref_table'])) {
continue;
}
$schema = $this->tcaSchemaFactory->get($row['ref_table']);
if ($record) {
if (!$this->canAccessPage($schema, $record)) {
continue;
}
$urlParameters = [
'edit' => [
$row['ref_table'] => [
$row['ref_uid'] => 'edit',
],
],
'returnUrl' => $request->getAttribute('normalizedParams')->getRequestUri(),
];
$url = (string)$this->uriBuilder->buildUriFromRoute('record_edit', $urlParameters);
$line['url'] = $url;
$line['icon'] = $this->iconFactory->getIconForRecord($row['ref_table'], $record, IconSize::SMALL)->render();
$line['row'] = $row;
$line['record'] = $record;
$line['recordTitle'] = BackendUtility::getRecordTitle($row['ref_table'], $record);
$line['title'] = $schema->getTitle($lang->sL(...));
$line['labelForTableColumn'] = $this->getLabelForTableColumn($schema, $row['field']);
$line['path'] = BackendUtility::getRecordPath($record['pid'], '', 0);
$line['actions'] = $this->getRecordActions($schema, $row['ref_uid'], $request);
} else {
$line['row'] = $row;
$line['title'] = $schema->getTitle($lang->sL(...));
$line['labelForTableColumn'] = $this->getLabelForTableColumn($schema, $row['field']);
}
$refFromLines[] = $line;
}
return $refFromLines;
}
/**
* Convert FAL file reference (sys_file_reference) to reference index (sys_refindex) table format
*/
protected function transformFileReferenceToRecordReference(array $referenceRecord): ?array
{
$queryBuilder = $this->connectionPool
->getQueryBuilderForTable('sys_file_reference');
$queryBuilder->getRestrictions()->removeAll();
$fileReference = $queryBuilder
->select('*')
->from('sys_file_reference')
->where(
$queryBuilder->expr()->eq(
'uid',
$queryBuilder->createNamedParameter($referenceRecord['recuid'], Connection::PARAM_INT)
)
)
->executeQuery()
->fetchAssociative();
return $fileReference ? [
'recuid' => $fileReference['uid_foreign'],
'tablename' => $fileReference['tablenames'],
'field' => $fileReference['fieldname'],
'flexpointer' => '',
'softref_key' => '',
'sorting' => $fileReference['sorting_foreign'],
] : null;
}
/**
* @param array $record Record to be checked (ensure pid is resolved for workspaces)
*/
protected function canAccessPage(TcaSchema $schema, array $record): bool
{
$recordPid = (int)($schema->getName() === 'pages' ? $record['uid'] : $record['pid']);
$isInWebMount = (bool)$this->getBackendUser()->isInWebMount($schema->getName() === 'pages' ? $record : $record['pid']);
return $isInWebMount || ($recordPid === 0 && $schema->getCapability(TcaSchemaCapability::RestrictionRootLevel)->shallIgnoreRootLevelRestriction());
}
protected function getLanguageService(): LanguageService
{
return $GLOBALS['LANG'];
}
protected function getBackendUser(): BackendUserAuthentication
{
return $GLOBALS['BE_USER'];
}
}
@@ -0,0 +1,127 @@
<?php
declare(strict_types=1);
/*
* 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 TYPO3\CMS\Backend\Controller\ContentElement;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Backend\Attribute\AsController;
use TYPO3\CMS\Backend\Template\PageRendererBackendSetupTrait;
use TYPO3\CMS\Backend\Tree\View\ContentMovingPagePositionMap;
use TYPO3\CMS\Backend\Utility\BackendUtility;
use TYPO3\CMS\Backend\View\BackendViewFactory;
use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
use TYPO3\CMS\Core\Configuration\ExtensionConfiguration;
use TYPO3\CMS\Core\Http\HtmlResponse;
use TYPO3\CMS\Core\Localization\LanguageServiceFactory;
use TYPO3\CMS\Core\Page\JavaScriptModuleInstruction;
use TYPO3\CMS\Core\Page\PageRenderer;
use TYPO3\CMS\Core\Type\Bitmask\Permission;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* The "move tt_content element" wizard. Reachable via records module "Re-position content element" on tt_content records.
*
* @internal This class is a specific Backend controller implementation and is not considered part of the Public TYPO3 API.
*/
#[AsController]
final readonly class MoveElementController
{
use PageRendererBackendSetupTrait;
public function __construct(
private PageRenderer $pageRenderer,
private BackendViewFactory $backendViewFactory,
private LanguageServiceFactory $languageServiceFactory,
private ExtensionConfiguration $extensionConfiguration
) {}
public function mainAction(ServerRequestInterface $request): ResponseInterface
{
$this->setUpBasicPageRendererForBackend(
$this->pageRenderer,
$this->extensionConfiguration,
$request,
$this->languageServiceFactory->createFromUserPreferences($this->getBackendUser())
);
$view = $this->backendViewFactory->create($request);
$queryParams = $request->getQueryParams();
$contentOnly = $queryParams['contentOnly'] ?? false;
$this->pageRenderer->loadJavaScriptModule('@typo3/backend/global-event-handler.js');
$this->pageRenderer->loadJavaScriptModule('@typo3/backend/tree/page-browser.js');
$this->pageRenderer->getJavaScriptRenderer()->addJavaScriptModuleInstruction(
JavaScriptModuleInstruction::create('@typo3/backend/wizard/move-content-element.js', 'MoveContentElement')->instance()
);
$this->pageRenderer->addInlineLanguageLabelFile('EXT:core/Resources/Private/Language/locallang_misc.xlf');
$this->pageRenderer->addInlineLanguageLabelFile('EXT:backend/Resources/Private/Language/Wizards/move_content_elements.xlf');
$view->assignMultiple(array_merge($this->getContentVariables($request), [
'contentOnly' => $contentOnly,
]));
$content = $view->render('ContentElement/MoveElement');
if ($contentOnly) {
return new HtmlResponse($content);
}
$this->pageRenderer->setBodyContent('<body>' . $content);
return new HtmlResponse($this->pageRenderer->render($request));
}
private function getContentVariables(ServerRequestInterface $request): array
{
$queryParams = $request->getQueryParams();
$parsedBody = $request->getParsedBody();
$contentElementUid = (int)($parsedBody['uid'] ?? $queryParams['uid'] ?? 0);
$pageId = (int)($parsedBody['expandPage'] ?? $queryParams['expandPage'] ?? 0);
$sysLanguage = (int)($parsedBody['sys_language'] ?? $queryParams['sys_language'] ?? 0);
$makeCopy = (bool)($parsedBody['makeCopy'] ?? $queryParams['makeCopy'] ?? 0);
$permsClause = $this->getBackendUser()->getPagePermsClause(Permission::PAGE_SHOW);
if (!$contentElementUid) {
return [];
}
$contentElement = BackendUtility::getRecordWSOL('tt_content', $contentElementUid);
$pageInfo = BackendUtility::readPageAccess($pageId, $permsClause);
$contentElementTitle = BackendUtility::getRecordTitle('tt_content', $contentElement);
$assigns = [
'record' => $contentElement,
'makeCopyChecked' => $makeCopy,
'pageInfo' => $pageInfo,
'recordTitle' => BackendUtility::cropToTitleLength($contentElementTitle),
];
if (is_array($pageInfo) && $this->getBackendUser()->isInWebMount($pageInfo['uid'], $permsClause)) {
// Initialize the content position map:
$contentPositionMap = GeneralUtility::makeInstance(ContentMovingPagePositionMap::class);
$contentPositionMap->copyMode = $makeCopy ? 'copy' : 'move';
$contentPositionMap->moveUid = $contentElementUid;
$contentPositionMap->cur_sys_language = $sysLanguage;
$pageTitle = BackendUtility::getRecordTitle('pages', $pageInfo);
$assigns['pageRecord']['recordTooltip'] = BackendUtility::getRecordIconAltText($pageInfo, 'pages', false);
$assigns['pageRecord']['recordTitle'] = BackendUtility::cropToTitleLength($pageTitle);
$assigns['contentElementColumns'] = $contentPositionMap->printContentElementColumns($pageId, $pageInfo, $request);
}
return $assigns;
}
private function getBackendUser(): BackendUserAuthentication
{
return $GLOBALS['BE_USER'];
}
}
@@ -0,0 +1,710 @@
<?php
declare(strict_types=1);
/*
* 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 TYPO3\CMS\Backend\Controller\ContentElement;
use Psr\EventDispatcher\EventDispatcherInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Backend\Attribute\AsController;
use TYPO3\CMS\Backend\Controller\Event\ModifyNewContentElementWizardItemsEvent;
use TYPO3\CMS\Backend\Form\Utility\FormEngineUtility;
use TYPO3\CMS\Backend\Routing\UriBuilder;
use TYPO3\CMS\Backend\Tree\View\ContentCreationPagePositionMap;
use TYPO3\CMS\Backend\Utility\BackendUtility;
use TYPO3\CMS\Backend\View\BackendLayoutView;
use TYPO3\CMS\Backend\View\BackendViewFactory;
use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
use TYPO3\CMS\Core\Http\HtmlResponse;
use TYPO3\CMS\Core\Localization\LanguageService;
use TYPO3\CMS\Core\Schema\Struct\SelectItem;
use TYPO3\CMS\Core\Schema\TcaSchemaFactory;
use TYPO3\CMS\Core\Service\DependencyOrderingService;
use TYPO3\CMS\Core\Type\Bitmask\Permission;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Core\Utility\StringUtility;
/**
* New Content element wizard. This is the modal that pops up when clicking "+content" in page module, which
* will trigger wizardAction() since there is a colPos given. Method positionMapAction() is triggered for
* instance from the records module "+content" on tt_content table header, and from records module doc-header "+"
* and then "Click here for wizard".
*
* @internal This class is a specific Backend controller implementation and is not considered part of the Public TYPO3 API.
*/
#[AsController]
class NewContentElementController
{
protected int $id = 0;
protected int $uid_pid = 0;
protected array $pageInfo = [];
protected int $sys_language = 0;
protected string $returnUrl = '';
/**
* If set, the content is destined for a specific column.
*/
protected ?int $colPos = null;
public function __construct(
protected readonly UriBuilder $uriBuilder,
protected readonly BackendViewFactory $backendViewFactory,
protected readonly EventDispatcherInterface $eventDispatcher,
protected readonly DependencyOrderingService $dependencyOrderingService,
protected readonly TcaSchemaFactory $tcaSchemaFactory,
protected readonly BackendLayoutView $backendLayoutView,
) {}
/**
* Process incoming request and dispatch to the requested action
*/
public function handleRequest(ServerRequestInterface $request): ResponseInterface
{
$parsedBody = $request->getParsedBody();
$queryParams = $request->getQueryParams();
// Setting internal vars:
$this->id = (int)($parsedBody['id'] ?? $queryParams['id'] ?? 0);
$this->sys_language = (int)($parsedBody['language_tag'] ?? $queryParams['language_tag'] ?? 0);
$this->returnUrl = GeneralUtility::sanitizeLocalUrl($parsedBody['returnUrl'] ?? $queryParams['returnUrl'] ?? '', $request);
$colPos = $parsedBody['colPos'] ?? $queryParams['colPos'] ?? null;
$this->colPos = $colPos === null ? null : (int)$colPos;
$this->uid_pid = (int)($parsedBody['uid_pid'] ?? $queryParams['uid_pid'] ?? 0);
// Getting the current page and receiving access information
$this->pageInfo = BackendUtility::readPageAccess($this->id, $this->getBackendUser()->getPagePermsClause(Permission::PAGE_SHOW)) ?: [];
$action = (string)($parsedBody['action'] ?? $queryParams['action'] ?? 'wizard');
if ($action === 'wizard') {
return $this->wizardAction($request);
}
if ($action === 'positionMap') {
return $this->positionMapAction($request);
}
return new HtmlResponse('Action not allowed', 400);
}
/**
* Renders the wizard
*/
protected function wizardAction(ServerRequestInterface $request): ResponseInterface
{
if (!$this->id || $this->pageInfo === []) {
// No pageId or no access.
return new HtmlResponse('No Access');
}
// Whether position selection must be performed (no colPos was yet defined)
$positionSelection = $this->colPos === null;
// Get processed and modified wizard items
$wizardItems = $this->eventDispatcher->dispatch(
new ModifyNewContentElementWizardItemsEvent(
$this->getWizards($request),
$this->pageInfo,
$this->colPos,
$this->sys_language,
$this->uid_pid,
$request,
)
)->getWizardItems();
$key = 'common';
$categories = [];
foreach ($wizardItems as $wizardKey => $wizardItem) {
// An item is either a header or an item rendered with title/description and icon:
if (isset($wizardItem['header'])) {
$key = $wizardKey;
$categories[$key] = [
'identifier' => $key,
'label' => $wizardItem['header'] ?: '-',
'items' => [],
];
} else {
// Get default values for the wizard item
$defaultValues = (array)($wizardItem['defaultValues'] ?? []);
// Initialize the view variables for the item
$item = [
'identifier' => $wizardKey,
'icon' => $wizardItem['iconIdentifier'] ?? '',
'iconOverlay' => $wizardItem['iconOverlay'] ?? '',
'label' => $wizardItem['title'] ?? '',
'description' => $wizardItem['description'] ?? '',
'defaultValues' => $defaultValues,
];
// If the URL was already created (e.g. via the PSR-14 event) this needs to be
// kept and not overwritten
if (isset($wizardItem['url'])) {
$item['url'] = $wizardItem['url'];
if ($positionSelection) {
$item['requestType'] = 'ajax';
$item['saveAndClose'] = (bool)($wizardItem['saveAndClose'] ?? false);
}
} elseif ($positionSelection) {
$item['url'] = (string)$this->uriBuilder
->buildUriFromRoute(
'new_content_element_wizard',
[
'action' => 'positionMap',
'id' => $this->id,
'sys_language_uid' => $this->sys_language,
'returnUrl' => $this->returnUrl,
]
);
$item['requestType'] = 'ajax';
$item['saveAndClose'] = (bool)($wizardItem['saveAndClose'] ?? false);
} else {
// In case no position has to be selected, we can just add the target
if ($wizardItem['saveAndClose'] ?? false) {
// Go to DataHandler directly instead of FormEngine
$item['url'] = (string)$this->uriBuilder->buildUriFromRoute('tce_db', [
'data' => [
'tt_content' => [
StringUtility::getUniqueId('NEW') => array_replace($defaultValues, [
'colPos' => $this->colPos,
'pid' => $this->uid_pid,
'sys_language_uid' => $this->sys_language,
]),
],
],
'redirect' => $this->returnUrl,
]);
} else {
$item['url'] = (string)$this->uriBuilder->buildUriFromRoute('record_edit', [
'edit' => [
'tt_content' => [
$this->uid_pid => 'new',
],
],
'module' => '_CURRENT_MODULE_',
'returnUrl' => $this->returnUrl,
'defVals' => [
'tt_content' => array_replace($defaultValues, [
'colPos' => $this->colPos,
'sys_language_uid' => $this->sys_language,
]),
],
]);
}
}
$categories[$key]['items'][] = $item;
}
}
// Unset empty categories
foreach ($categories as $key => $category) {
if ($category['items'] === []) {
unset($categories[$key]);
}
}
$view = $this->backendViewFactory->create($request);
$view->assignMultiple([
'positionSelection' => $positionSelection,
'categoriesJson' => GeneralUtility::jsonEncodeForHtmlAttribute($categories, false),
]);
return new HtmlResponse($view->render('NewContentElement/Wizard'));
}
/**
* Renders the position map
*/
protected function positionMapAction(ServerRequestInterface $request): ResponseInterface
{
$pageInfo = BackendUtility::readPageAccess($this->id, $this->getBackendUser()->getPagePermsClause(Permission::PAGE_SHOW));
$posMap = GeneralUtility::makeInstance(ContentCreationPagePositionMap::class);
$posMap->cur_sys_language = $this->sys_language;
$posMap->defVals = (array)($request->getParsedBody()['defVals'] ?? []);
$posMap->saveAndClose = (bool)($request->getParsedBody()['saveAndClose'] ?? false);
$posMap->R_URI = $this->returnUrl;
$view = $this->backendViewFactory->create($request);
$view->assign('posMap', $posMap->printContentElementColumns($this->id, $pageInfo, $request));
return new HtmlResponse($view->render('NewContentElement/PositionMap'));
}
/**
* Returns the array of elements in the wizard display.
* For the plugin section there is support for adding elements there from a global variable.
*/
protected function getWizards(ServerRequestInterface $request): array
{
$wizards = $this->loadAvailableWizards();
$newContentElementWizardTsConfig = BackendUtility::getPagesTSconfig($this->id)['mod.']['wizards.']['newContentElement.'] ?? [];
$wizardsFromPageTSConfig = $this->migrateCommonGroupToDefault($newContentElementWizardTsConfig['wizardItems.'] ?? []);
$wizardsFromPageTSConfig = $this->migratePositionalCommonGroupToDefault($wizardsFromPageTSConfig);
$wizards = $this->mergeContentElementWizardsWithPageTSConfigWizards($wizards, $wizardsFromPageTSConfig);
$wizards = $this->removeWizardsByPageTs($wizards, $newContentElementWizardTsConfig);
$wizards = $this->removeWizardsByBackendLayoutColPosRestriction($wizards, $this->pageInfo, $this->colPos, $request);
if ($wizards === []) {
return [];
}
$wizardItems = [];
foreach ($wizards as $groupKey => $wizardGroup) {
$wizards[$groupKey] = $this->prepareDependencyOrdering($wizards[$groupKey], 'before');
$wizards[$groupKey] = $this->prepareDependencyOrdering($wizards[$groupKey], 'after');
}
$orderedWizards = $this->orderWizards($wizards);
foreach ($orderedWizards as $groupKey => $wizardGroup) {
$groupKey = rtrim($groupKey, '.');
$groupItems = [];
$wizardElements = $wizardGroup['elements.'] ?? [];
if (is_array($wizardElements)) {
$wizardElements = $this->orderElements($wizardElements);
foreach ($wizardElements as $itemKey => $itemConf) {
$itemKey = rtrim($itemKey, '.');
if ($itemConf !== []) {
$groupItems[$groupKey . '_' . $itemKey] = $this->prepareWizardItem($itemConf);
}
}
}
if (!empty($groupItems)) {
$wizardItems[$groupKey]['header'] = $this->getLanguageService()->sL($wizardGroup['header'] ?? '');
$wizardItems = array_merge($wizardItems, $groupItems);
}
}
// Remove elements where preset values are not allowed:
return $this->removeInvalidWizardItems($wizardItems);
}
protected function loadAvailableWizards(): array
{
$schema = $this->tcaSchemaFactory->get('tt_content');
// Foreign table support for TypeInformation is not supported in tt_content
$typeField = $schema->getSubSchemaTypeInformation()->getFieldName();
$fieldConfig = $schema->hasField($typeField) ? $schema->getField($typeField)->getConfiguration() : [];
$items = $fieldConfig['items'] ?? [];
$itemGroups = $fieldConfig['itemGroups'] ?? [];
$groupedWizardItems = [];
foreach (array_keys($itemGroups) as $groupIdentifier) {
$groupedWizardItems[$groupIdentifier . '.']['header'] = $itemGroups[$groupIdentifier];
}
foreach ($items as $item) {
$selectItem = SelectItem::fromTcaItemArray($item);
if ($selectItem->isDivider()) {
continue;
}
$recordType = $selectItem->getValue();
$groupIdentifier = $selectItem->getGroup();
$groupedWizardItems[$groupIdentifier . '.']['elements.'] ??= [];
// In case this group is not defined in itemGroups, use the group identifier as label.
$groupedWizardItems[$groupIdentifier . '.']['header'] ??= $groupIdentifier;
$itemDescription = $selectItem->getDescription();
$wizardEntry = [
'iconIdentifier' => $selectItem->getIcon(),
'iconOverlay' => $selectItem->getIconOverlay(),
'title' => $selectItem->getLabel(),
'description' => $itemDescription['description'] ?? ($itemDescription ?? ''),
'defaultValues' => [
'CType' => $recordType,
],
];
if ($schema->hasSubSchema($recordType)) {
$wizardEntry = array_replace_recursive($wizardEntry, $schema->getSubSchema($recordType)->getRawConfiguration()['creationOptions'] ?? []);
}
$groupedWizardItems[$groupIdentifier . '.']['elements.'][$recordType . '.'] = $wizardEntry;
}
return $groupedWizardItems;
}
/**
* This method merges Content Element wizards defined by TCA with wizards defined in PageTSConfig.
* PageTS has precedence.
* It might happen that both TCA and PageTS define an entry with exactly the same default values.
* In such a case, the automatically added TCA entry is dropped.
*/
protected function mergeContentElementWizardsWithPageTSConfigWizards(array $contentElementWizards, array $pageTsConfigWizards): array
{
$uniqueDefaultValuesInPageTsWizards = [];
foreach ($pageTsConfigWizards as $wizard) {
foreach ($wizard['elements.'] ?? [] as $elementConfig) {
$defaultValues = $elementConfig['tt_content_defValues.'] ?? [];
if ($defaultValues === []) {
continue;
}
ksort($defaultValues);
$uniqueDefaultValuesInPageTsWizards[] = $defaultValues;
}
}
foreach ($contentElementWizards as $group => $wizard) {
foreach ($wizard['elements.'] ?? [] as $key => $elementConfig) {
// Remove duplicated entry.
$defaultValues = $elementConfig['defaultValues'];
ksort($defaultValues);
if (in_array($defaultValues, $uniqueDefaultValuesInPageTsWizards, true)) {
unset($contentElementWizards[$group]['elements.'][$key]);
}
}
}
$mergedWizards = array_replace_recursive($contentElementWizards, $pageTsConfigWizards);
return $mergedWizards;
}
/**
* Orders elements within a wizard group using before/after configuration.
* Similar to orderWizards() but for individual content elements.
*/
protected function orderElements(array $elements): array
{
// Check if any element has before/after configuration
// and return early if no reordering is required.
if (!$this->hasPositionalArguments($elements)) {
return $elements;
}
// Prepare elements for dependency ordering.
// Create implicit chain based on initial order for consecutive elements
// without explicit dependencies, preserving relative order while allowing
// explicit positioning.
$preparedElements = [];
// First pass: prepare all elements with their explicit dependencies
foreach ($elements as $elementKey => $element) {
$preparedElement = $element;
// Prepare before/after values (they might be comma-separated strings)
$preparedElement = $this->prepareDependencyOrdering($preparedElement, 'before');
$preparedElement = $this->prepareDependencyOrdering($preparedElement, 'after');
$preparedElements[$elementKey] = $preparedElement;
}
// Second pass: add implicit chain for consecutive elements without explicit dependencies
// This preserves relative order within blocks, while explicit dependencies can reorder them
$previousIndependentElementKey = null;
foreach ($elements as $elementKey => $element) {
$isIndependent = empty($element['before']) && empty($element['after']);
if ($isIndependent) {
// Element without explicit dependency: chain with previous independent element
if ($previousIndependentElementKey !== null) {
$existingAfter = $preparedElements[$elementKey]['after'] ?? [];
if (!in_array($previousIndependentElementKey, $existingAfter, true)) {
$preparedElements[$elementKey]['after'] = array_merge($existingAfter, [$previousIndependentElementKey]);
}
}
$previousIndependentElementKey = $elementKey;
}
}
// Use dependency ordering service to order elements
return $this->dependencyOrderingService->orderByDependencies($preparedElements);
}
protected function hasPositionalArguments(array $elements): bool
{
foreach ($elements as $element) {
if (!empty($element['before']) || !empty($element['after'])) {
return true;
}
}
return false;
}
/**
* There are two separate ordering systems for wizard groups:
* 1. TCA itemGroup sorting by associative array item order.
* 2. PageTS defined order by "before" and "after".
*
* System 1. has a well-defined order, where every item defines "after" (linked list).
* Due to this, the two system cannot be combined.
* As soon as system 2 defines at least one "before" or "after" it takes over.
*/
protected function orderWizards(array $wizards): array
{
// First round: Order by TCA defined sorting.
$hasAtLeastOnePositionalArgument = false;
foreach ($wizards as $group => $wizard) {
if (isset($wizard['before'])) {
$hasAtLeastOnePositionalArgument = true;
$wizards[$group]['pageTsBefore'] = $wizard['before'];
unset($wizards[$group]['before']);
}
if (isset($wizard['after'])) {
$hasAtLeastOnePositionalArgument = true;
$wizards[$group]['pageTsAfter'] = $wizard['after'];
unset($wizards[$group]['after']);
}
}
// No order defined by pageTS. Use TCA sorting.
if (!$hasAtLeastOnePositionalArgument) {
$schema = $this->tcaSchemaFactory->get('tt_content');
// Foreign table support for TypeInformation is not supported in tt_content
$typeField = $schema->getSubSchemaTypeInformation()->getFieldName();
$fieldConfig = $schema->hasField($typeField) ? $schema->getField($typeField)->getConfiguration() : [];
$itemGroups = $fieldConfig['itemGroups'] ?? [];
// Auto-set positional information based on TCA itemGroups sorting.
$lastGroup = null;
foreach (array_keys($itemGroups) as $groupIdentifier) {
if (!array_key_exists($groupIdentifier . '.', $wizards)) {
continue;
}
if ($lastGroup !== null) {
$wizards[$groupIdentifier . '.']['after'] = [$lastGroup . '.'];
}
$lastGroup = $groupIdentifier;
}
return $this->dependencyOrderingService->orderByDependencies($wizards);
}
// Override order by pageTsConfig.
foreach ($wizards as $group => $wizard) {
// Unset "after" previously set by Content Element wizards.
unset($wizards[$group]['after']);
if (isset($wizard['pageTsBefore'])) {
$wizards[$group]['before'] = $wizard['pageTsBefore'];
unset($wizards[$group]['pageTsBefore']);
}
if (isset($wizard['pageTsAfter'])) {
$wizards[$group]['after'] = $wizard['pageTsAfter'];
unset($wizards[$group]['pageTsAfter']);
}
}
return $this->dependencyOrderingService->orderByDependencies($wizards);
}
/**
* This method returns the wizard items, defined in Page TSconfig for b/w
* compatibility.
*
* Additionally, it migrates previously defined wizard items in the
* `common` group to the new `default` group, which is defined in TCA.
*
* @param array<string, array> $wizardsFromPageTs
* @return array<string, array>
*/
protected function migrateCommonGroupToDefault(array $wizardsFromPageTs): array
{
if (!array_key_exists('common.', $wizardsFromPageTs)) {
// In case "common." is not defined, just return the wizards, which are still defined via Page TSconfig
return $wizardsFromPageTs;
}
// Prepare "removeItems" to be merged
if ($wizardsFromPageTs['default.']['elements.']['removeItems'] ?? false) {
$wizardsFromPageTs['default.']['removeItems'] = GeneralUtility::trimExplode(',', $wizardsFromPageTs['default.']['elements.']['removeItems'] ?? '', true);
} elseif ($wizardsFromPageTs['default.']['removeItems'] ?? false) {
$wizardsFromPageTs['default.']['removeItems'] = GeneralUtility::trimExplode(',', $wizardsFromPageTs['default.']['removeItems'], true);
}
if ($wizardsFromPageTs['common.']['elements.']['removeItems'] ?? false) {
$wizardsFromPageTs['common.']['removeItems'] = GeneralUtility::trimExplode(',', $wizardsFromPageTs['common.']['elements.']['removeItems'] ?? '', true);
} elseif ($wizardsFromPageTs['common.']['removeItems'] ?? false) {
$wizardsFromPageTs['common.']['removeItems'] = GeneralUtility::trimExplode(',', $wizardsFromPageTs['common.']['removeItems'], true);
}
$defaultItems = array_merge_recursive($wizardsFromPageTs['default.'] ?? [], $wizardsFromPageTs['common.']);
unset($wizardsFromPageTs['common.']);
if ($defaultItems !== []) {
$wizardsFromPageTs['default.'] = $defaultItems;
}
return $wizardsFromPageTs;
}
protected function migratePositionalCommonGroupToDefault(array $wizards): array
{
foreach ($wizards as $group => $wizard) {
if (($wizard['before'] ?? '') === 'common') {
$wizards[$group]['before'] = 'default';
}
if (($wizard['after'] ?? '') === 'common') {
$wizards[$group]['after'] = 'default';
}
}
return $wizards;
}
protected function prepareWizardItem(array $itemConf): array
{
// Just replace the "known" keys of $itemConf. This way extensions are able to set custom keys, which are not
// used by the controller, but might be evaluated by listeners of the ModifyNewContentElementWizardItemsEvent.
$itemConf = array_replace_recursive(
$itemConf,
[
'title' => trim($this->getLanguageService()->sL($itemConf['title'] ?? '')),
'description' => trim($this->getLanguageService()->sL($itemConf['description'] ?? '')),
'iconIdentifier' => $itemConf['iconIdentifier'] ?? null,
'saveAndClose' => (bool)($itemConf['saveAndClose'] ?? false),
'defaultValues' => array_replace_recursive(
$itemConf['tt_content_defValues'] ?? [],
$itemConf['tt_content_defValues.'] ?? [],
$itemConf['defaultValues'] ?? []
),
]
);
unset($itemConf['tt_content_defValues'], $itemConf['tt_content_defValues.']);
return $itemConf;
}
protected function removeWizardsByPageTs(array $wizards, mixed $wizardsItemsPageTs): array
{
$removeWizardItems = $wizardsItemsPageTs['wizardItems.']['removeItems'] ?? [];
if (is_string($removeWizardItems)) {
$removeWizardItems = GeneralUtility::trimExplode(',', $removeWizardItems, true);
}
foreach ($wizards as $key => &$wizard) {
// Leave out removeItems etc.
if (is_string($wizard)) {
unset($wizards[$key]);
continue;
}
if (in_array(rtrim((string)$key, '.'), $removeWizardItems, true)) {
unset($wizards[$key]);
continue;
}
$removeWizardElements = $wizardsItemsPageTs['wizardItems.'][$key]['removeItems'] ?? [];
if (is_string($removeWizardElements)) {
$removeWizardElements = GeneralUtility::trimExplode(',', $removeWizardElements, true);
}
foreach ($wizard['elements.'] ?? [] as $identifier => $element) {
if (in_array(rtrim((string)$identifier, '.'), $removeWizardElements, true)) {
unset($wizard['elements.'][$identifier]);
}
}
}
return $wizards;
}
protected function removeWizardsByBackendLayoutColPosRestriction(array $wizardGroups, array $pageInfo, ?int $colPos, ServerRequestInterface $request): array
{
// Force colPos to 0 if null to apply restrictions for 0 by default.
$colPos = (int)$colPos;
// This is the page uid of a workspace overlay already so backend layouts of workspace
// changed or moved pages should be considered correctly.
$pid = (int)$pageInfo['uid'];
$backendLayout = $this->backendLayoutView->getBackendLayoutForPage($pid);
$columnConfiguration = $this->backendLayoutView->getColPosConfigurationForPage($backendLayout, $colPos, $pid, $request);
if (!empty($columnConfiguration['allowedContentTypes'])) {
$allowedContentTypes = GeneralUtility::trimExplode(',', $columnConfiguration['allowedContentTypes'], true);
foreach ($wizardGroups as $wizardGroupName => $wizards) {
foreach (($wizards['elements.'] ?? []) as $wizardKey => $wizard) {
$cType = $wizard['defaultValues']['CType'] ?? $wizard['tt_content_defValues.']['CType'] ?? '';
if (empty($cType)) {
continue;
}
if (!in_array(trim($cType), $allowedContentTypes, true)) {
unset($wizardGroups[$wizardGroupName]['elements.'][$wizardKey]);
}
}
}
}
if (!empty($columnConfiguration['disallowedContentTypes'])) {
$disAllowedContentTypes = GeneralUtility::trimExplode(',', $columnConfiguration['disallowedContentTypes'], true);
foreach ($wizardGroups as $wizardGroupName => $wizards) {
foreach (($wizards['elements.'] ?? []) as $wizardKey => $wizard) {
$cType = $wizard['defaultValues']['CType'] ?? $wizard['tt_content_defValues.']['CType'] ?? '';
if (empty($cType)) {
continue;
}
if (in_array(trim($cType), $disAllowedContentTypes, true)) {
unset($wizardGroups[$wizardGroupName]['elements.'][$wizardKey]);
}
}
}
}
return $wizardGroups;
}
/**
* Checks the array for elements which might contain invalid default values and will unset them!
* Looks for the "defaultValues" key in each element and if found it will traverse that array
* as fieldname / value pairs and check.
*/
protected function removeInvalidWizardItems(array $wizardItems): array
{
$schema = $this->tcaSchemaFactory->get('tt_content');
$removeItems = [];
$keepItems = [];
// Get TCEFORM from TSconfig of current page
$TCEFORM_TSconfig = FormEngineUtility::getTCEFORM_TSconfig('tt_content', ['pid' => $this->id]);
$backendUser = $this->getBackendUser();
// Traverse wizard items:
foreach ($wizardItems as $key => $cfg) {
if (!is_array($cfg['defaultValues'] ?? false)) {
continue;
}
// This is not a group; this is likely broken configuration
if ($cfg['defaultValues'] === []) {
unset($wizardItems[$key]);
}
// If defaultValues are defined, check access by traversing all fields with default values:
foreach ($cfg['defaultValues'] as $fieldName => $value) {
if (!$schema->hasField($fieldName)) {
continue;
}
// Get information about if the field value is OK:
$config = $schema->getField($fieldName)->getConfiguration();
$userNotAllowedToAccess = ($config['type'] ?? '') === 'select' && ($config['authMode'] ?? false)
&& !$backendUser->checkAuthMode('tt_content', $fieldName, $value);
// Check removeItems
if (!isset($removeItems[$fieldName]) && ($TCEFORM_TSconfig[$fieldName]['removeItems'] ?? false)) {
$removeItems[$fieldName] = array_flip(GeneralUtility::trimExplode(
',',
$TCEFORM_TSconfig[$fieldName]['removeItems'],
true
));
}
// Check keepItems
if (!isset($keepItems[$fieldName]) && ($TCEFORM_TSconfig[$fieldName]['keepItems'] ?? false)) {
$keepItems[$fieldName] = array_flip(GeneralUtility::trimExplode(
',',
$TCEFORM_TSconfig[$fieldName]['keepItems'],
true
));
}
$isNotInKeepItems = !empty($keepItems[$fieldName]) && !isset($keepItems[$fieldName][$value]);
if ($userNotAllowedToAccess || ($fieldName === 'CType' && (isset($removeItems[$fieldName][$value]) || $isNotInKeepItems))) {
// Remove element all together:
unset($wizardItems[$key]);
break;
}
// Add the parameter:
$wizardItems[$key]['defaultValues'][$fieldName] = $this->getLanguageService()->sL($value);
}
}
return $wizardItems;
}
/**
* Prepare a wizard tab configuration for sorting.
*/
protected function prepareDependencyOrdering(array $wizardGroup, string $key): array
{
if (is_string($wizardGroup[$key] ?? null)) {
$wizardGroup[$key] = GeneralUtility::trimExplode(',', $wizardGroup[$key], true);
}
if (is_array($wizardGroup[$key] ?? null)) {
$wizardGroup[$key] = array_map(
static fn(string $s): string => rtrim($s, '.') . '.',
$wizardGroup[$key]
);
}
return $wizardGroup;
}
protected function getLanguageService(): LanguageService
{
return $GLOBALS['LANG'];
}
protected function getBackendUser(): BackendUserAuthentication
{
return $GLOBALS['BE_USER'];
}
}
@@ -0,0 +1,78 @@
<?php
declare(strict_types=1);
/*
* 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 TYPO3\CMS\Backend\Controller;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Backend\Clipboard\Clipboard;
use TYPO3\CMS\Backend\ContextMenu\ContextMenu;
use TYPO3\CMS\Core\Http\JsonResponse;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Script Class for the Context Sensitive Menu in TYPO3
* @internal This class is a specific Backend controller implementation and is not considered part of the Public TYPO3 API.
*/
class ContextMenuController
{
/**
* Renders a context menu
*/
public function getContextMenuAction(ServerRequestInterface $request): ResponseInterface
{
$contextMenu = GeneralUtility::makeInstance(ContextMenu::class);
$params = $request->getQueryParams();
$table = $params['table'] ?? '';
$identifier = $params['uid'] ?? '';
$context = $params['context'] ?? '';
if ($table === '' || $identifier === '') {
return new JsonResponse([], 400);
}
$items = $contextMenu->getItems($table, $identifier, $context);
return new JsonResponse($items);
}
public function clipboardAction(ServerRequestInterface $request): ResponseInterface
{
$clipboard = GeneralUtility::makeInstance(Clipboard::class);
$clipboard->initializeClipboard($request);
$clipboard->lockToNormal();
$queryParams = $request->getQueryParams();
$parsedBody = $request->getParsedBody();
$clipboardCommand = array_replace_recursive($queryParams['CB'] ?? [], $parsedBody['CB'] ?? []);
// URL-decoded keys are required for the clipboard to recognize file identifiers (e.g. _FILE|...)
if (isset($clipboardCommand['el']) && is_array($clipboardCommand['el'])) {
$decodedElements = [];
foreach ($clipboardCommand['el'] as $key => $value) {
$decodedElements[urldecode((string)$key)] = $value;
}
$clipboardCommand['el'] = $decodedElements;
}
$clipboard->setCmd($clipboardCommand);
$clipboard->cleanCurrent();
$clipboard->endClipboard();
return new JsonResponse([]);
}
}
@@ -0,0 +1,453 @@
<?php
declare(strict_types=1);
/*
* 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 TYPO3\CMS\Backend\Controller;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\UriInterface;
use TYPO3\CMS\Backend\Attribute\AsController;
use TYPO3\CMS\Backend\Dto\FormElementData;
use TYPO3\CMS\Backend\Form\Exception\AccessDeniedException;
use TYPO3\CMS\Backend\Form\Exception\DatabaseRecordException;
use TYPO3\CMS\Backend\Form\Exception\DatabaseRecordWorkspaceDeletePlaceholderException;
use TYPO3\CMS\Backend\Form\Exception\NoFieldsToRenderException;
use TYPO3\CMS\Backend\Form\FormAction;
use TYPO3\CMS\Backend\Form\FormDataCompiler;
use TYPO3\CMS\Backend\Form\FormDataGroup\TcaDatabaseRecord;
use TYPO3\CMS\Backend\Form\FormResultCollection;
use TYPO3\CMS\Backend\Form\FormResultFactory;
use TYPO3\CMS\Backend\Form\FormResultHandler;
use TYPO3\CMS\Backend\Form\NodeFactory;
use TYPO3\CMS\Backend\Module\ModuleProvider;
use TYPO3\CMS\Backend\Routing\UriBuilder;
use TYPO3\CMS\Backend\Template\ModuleTemplate;
use TYPO3\CMS\Backend\Template\ModuleTemplateFactory;
use TYPO3\CMS\Backend\Utility\BackendUtility;
use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
use TYPO3\CMS\Core\DataHandling\DataHandler;
use TYPO3\CMS\Core\Http\RedirectResponse;
use TYPO3\CMS\Core\Imaging\IconFactory;
use TYPO3\CMS\Core\Imaging\IconSize;
use TYPO3\CMS\Core\Localization\LanguageService;
use TYPO3\CMS\Core\Page\JavaScriptModuleInstruction;
use TYPO3\CMS\Core\Page\PageRenderer;
use TYPO3\CMS\Core\Schema\TcaSchemaFactory;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Core\Versioning\VersionState;
/**
* Lightweight controller for editing a single existing record inside the context panel.
*
* This is a focused alternative to EditDocumentController that only handles editing
* one existing record. It renders the same FormEngine form but with a minimal contextual
* template, communicating save/close signals to the parent frame via JavaScript.
*
* @internal This controller is not part of the TYPO3 public API.
*/
#[AsController]
readonly class ContextualRecordEditController
{
public function __construct(
private PageRenderer $pageRenderer,
private UriBuilder $uriBuilder,
private ModuleTemplateFactory $moduleTemplateFactory,
private ModuleProvider $moduleProvider,
private FormDataCompiler $formDataCompiler,
private NodeFactory $nodeFactory,
private FormResultFactory $formResultFactory,
private FormResultHandler $formResultHandler,
private TcaSchemaFactory $tcaSchemaFactory,
private IconFactory $iconFactory,
) {}
public function mainAction(ServerRequestInterface $request): ResponseInterface
{
return $request->getMethod() === 'POST' ? $this->persistAction($request) : $this->renderAction($request);
}
/**
* Handle POST: process save/close via DataHandler and redirect back
*/
private function persistAction(ServerRequestInterface $request): ResponseInterface
{
$queryParams = $request->getQueryParams();
$editConf = $this->parseAndValidateEditConf($queryParams['edit'] ?? []);
$table = $editConf['table'];
$uid = $this->resolveOverlayUid($table, $editConf['uid']);
$requestAction = FormAction::createFromRequest($request);
// Handle close (without save)
if ($requestAction->shouldHandleDocumentClosing()) {
return $this->redirectToSelf($queryParams, ['closed' => '1']);
}
$saveSucceeded = false;
if ($requestAction->shouldProcessData()) {
$saveSucceeded = $this->processData($request, $table, $uid);
if ($saveSucceeded && $requestAction->shouldCloseAfterSave()) {
return $this->redirectToSelf($queryParams, ['closed' => '1', 'justSaved' => '1']);
}
}
// POST-redirect-GET
$flags = ['edit' => [$table => [$uid => 'edit']]];
if ($saveSucceeded) {
$flags['justSaved'] = '1';
}
return $this->redirectToSelf($queryParams, $flags);
}
/**
* Handle GET: compile the FormEngine form and render the contextual edit template.
*/
private function renderAction(ServerRequestInterface $request): ResponseInterface
{
$view = $this->moduleTemplateFactory->create($request);
$view->setUiBlock(true);
$queryParams = $request->getQueryParams();
$editConf = $this->parseAndValidateEditConf($queryParams['edit'] ?? []);
$table = $editConf['table'];
$uid = $this->resolveOverlayUid($table, $editConf['uid']);
$returnUrl = GeneralUtility::sanitizeLocalUrl($queryParams['returnUrl'] ?? '', $request);
$overrideVals = is_array($queryParams['overrideVals'] ?? false) ? $queryParams['overrideVals'] : [];
$columnsOnly = $this->prepareColumnsOnlyConfiguration($queryParams['columnsOnly'] ?? null, $table);
$module = $this->moduleProvider->getModule((string)($queryParams['module'] ?? ''), $this->getBackendUser());
if ($module !== null) {
$view->setModuleName($module->getIdentifier());
}
// Compile FormEngine form
$currentEditingUrl = $this->uriBuilder->buildUriFromRoute('record_edit_contextual', array_merge($queryParams, [
'edit' => [$table => [$uid => 'edit']],
'returnUrl' => $returnUrl,
]));
$formResult = $this->compileForm($request, $view, $table, $uid, $overrideVals, $columnsOnly, $currentEditingUrl);
$firstEl = $formResult['element'] ?? null;
if ($firstEl !== null) {
$this->formResultHandler->addAssets($formResult['results']);
$body = '
<form action="' . htmlspecialchars((string)$currentEditingUrl) . '" method="post" enctype="multipart/form-data" name="editform" id="ContextualRecordEditController">
' . $formResult['results']->getHtml() . '
<input type="hidden" name="returnUrl" value="' . htmlspecialchars($returnUrl) . '" />
<input type="hidden" name="closeDoc" value="0" />
</form>';
} else {
$view->setUiBlock(false);
$body = $formResult['errorHtml'] ?? $this->getInfobox(
$this->getLanguageService()->sL('LLL:EXT:backend/Resources/Private/Language/locallang_alt_doc.xlf:noEditForm.message'),
$this->getLanguageService()->sL('LLL:EXT:backend/Resources/Private/Language/locallang_alt_doc.xlf:noEditForm'),
);
}
$recordTitle = $firstEl !== null && trim($firstEl->title) !== ''
? $firstEl->title
: '[' . $this->getLanguageService()->sL('core.core:labels.no_title') . ']';
$recordTitle = BackendUtility::cropToTitleLength($recordTitle);
// Contextual JS module with options
$contextualOptions = [];
if ($queryParams['justSaved'] ?? false) {
$contextualOptions['justSaved'] = true;
$contextualOptions['savedRecordTitle'] = $recordTitle;
}
if ($queryParams['closed'] ?? false) {
$contextualOptions['closed'] = true;
}
$this->pageRenderer->getJavaScriptRenderer()->addJavaScriptModuleInstruction(
JavaScriptModuleInstruction::create('@typo3/backend/contextual-record-edit.js')->instance($contextualOptions)
);
$this->pageRenderer->loadJavaScriptModule('@typo3/backend/context-menu.js');
$this->pageRenderer->loadJavaScriptModule('@typo3/backend/localization.js');
// Template variables
$view->assign('bodyHtml', $body);
$view->assign('recordTitle', $recordTitle);
// Full edit URL points to the standard EditDocumentController
$fullEditParams = [
'edit' => [$table => [$uid => 'edit']],
'returnUrl' => $returnUrl,
];
if ($module !== null) {
$fullEditParams['module'] = $module->getIdentifier();
}
$view->assign('fullEditUrl', (string)$this->uriBuilder->buildUriFromRoute('record_edit', $fullEditParams));
return $view->renderResponse('Form/ContextualRecordEdit');
}
/**
* Parse and validate the edit configuration. Ensures exactly one record with command "edit".
*
* @return array{table: string, uid: int}
*/
private function parseAndValidateEditConf(array|string $editConf): array
{
if (!is_array($editConf)) {
throw new \InvalidArgumentException('Invalid edit configuration', 1772580316);
}
$backendUser = $this->getBackendUser();
foreach ($editConf as $table => $conf) {
if (!is_array($conf) || !$this->tcaSchemaFactory->has($table)) {
continue;
}
if (!$backendUser->check('tables_modify', $table)) {
continue;
}
foreach ($conf as $uidList => $command) {
if ($command !== 'edit') {
continue;
}
$uid = (int)$uidList;
if ($uid > 0) {
return ['table' => $table, 'uid' => $uid];
}
}
}
throw new \InvalidArgumentException('ContextualRecordEditController requires exactly one existing record to edit', 1772580317);
}
private function prepareColumnsOnlyConfiguration(mixed $columnsOnly, string $table): array
{
if (!is_array($columnsOnly) || $columnsOnly === []) {
return [];
}
$finalColumnsOnly = array_map(
static fn($fields) => is_array($fields) ? $fields : GeneralUtility::trimExplode(',', $fields, true),
$columnsOnly
);
// Add slug generator fields as hidden fields
if (!empty($finalColumnsOnly[$table]) && $this->tcaSchemaFactory->has($table)) {
$schema = $this->tcaSchemaFactory->get($table);
foreach ($finalColumnsOnly[$table] as $fieldName) {
if (!$schema->hasField($fieldName)) {
continue;
}
$field = $schema->getField($fieldName);
$postModifiers = $field->getConfiguration()['generatorOptions']['postModifiers'] ?? [];
if ($field->isType(\TYPO3\CMS\Core\DataHandling\TableColumnType::SLUG)
&& (!is_array($postModifiers) || $postModifiers === [])
) {
$fieldGroups = $field->getConfiguration()['generatorOptions']['fields'] ?? [];
if (is_string($fieldGroups)) {
$fieldGroups = [$fieldGroups];
}
foreach ($fieldGroups as $fields) {
$finalColumnsOnly['__hiddenGeneratorFields'][$table] = array_merge(
$finalColumnsOnly['__hiddenGeneratorFields'][$table] ?? [],
(is_array($fields) ? $fields : GeneralUtility::trimExplode(',', $fields, true))
);
}
}
}
if (!empty($finalColumnsOnly['__hiddenGeneratorFields'][$table])) {
$finalColumnsOnly['__hiddenGeneratorFields'][$table] = array_diff(
array_unique($finalColumnsOnly['__hiddenGeneratorFields'][$table]),
$finalColumnsOnly[$table]
);
}
}
return $finalColumnsOnly;
}
/**
* Process save data via DataHandler.
*
* @return bool True if at least one record was saved without errors
*/
private function processData(ServerRequestInterface $request, string $table, int $uid): bool
{
$parsedBody = $request->getParsedBody();
$dataMap = $parsedBody['data'] ?? [];
$dataHandler = GeneralUtility::makeInstance(DataHandler::class);
$dataHandler->setControl($parsedBody['control'] ?? []);
$dataHandler->start($dataMap, $parsedBody['cmd'] ?? []);
if (is_array($parsedBody['mirror'] ?? null)) {
$dataHandler->setMirror($parsedBody['mirror']);
}
$dataHandler->process_datamap();
$dataHandler->process_cmdmap();
// Check if save succeeded (no errors for this record)
$erroneousRecords = $dataHandler->printLogErrorMessages();
return !in_array($table . '.' . $uid, $erroneousRecords, true) && isset($dataMap[$table][$uid]);
}
/**
* @return array{element: FormElementData, results: FormResultCollection}|array{errorHtml: string}
*/
private function compileForm(
ServerRequestInterface $request,
ModuleTemplate $view,
string $table,
int $uid,
array $overrideVals,
array $columnsOnly,
UriInterface $currentEditingUrl,
): array {
try {
$formDataCompilerInput = [
'request' => $request,
'tableName' => $table,
'vanillaUid' => $uid,
'command' => 'edit',
'returnUrl' => (string)$currentEditingUrl,
];
if ($overrideVals !== [] && is_array($overrideVals[$table] ?? null)) {
$formDataCompilerInput['overrideValues'] = $overrideVals[$table];
}
$formData = $this->formDataCompiler->compile($formDataCompilerInput, GeneralUtility::makeInstance(TcaDatabaseRecord::class));
// Display "is-locked" message
$lockInfo = BackendUtility::isRecordLocked($table, $formData['databaseRow']['uid']);
if ($lockInfo) {
$view->addFlashMessage($lockInfo['msg'], '', \TYPO3\CMS\Core\Type\ContextualFeedbackSeverity::WARNING);
}
$formElementData = new FormElementData(
title: $formData['recordTitle'],
table: $table,
uid: $formData['databaseRow']['uid'],
pid: $formData['databaseRow']['pid'] ?? 0,
record: $formData['databaseRow'],
viewId: 0,
command: 'edit',
userPermissionOnPage: $formData['userPermissionOnPage'],
);
BackendUtility::lockRecords($table, $formElementData->uid, $table === 'tt_content' ? $formElementData->pid : 0);
if (!empty($columnsOnly[$table])) {
$formData['fieldListToRender'] = implode(',', $columnsOnly[$table]);
if (!empty($columnsOnly['__hiddenGeneratorFields'][$table])) {
$formData['hiddenFieldListToRender'] = implode(',', $columnsOnly['__hiddenGeneratorFields'][$table]);
}
}
$formData['renderType'] = 'formWrapContainer';
$formResult = $this->nodeFactory->create($formData)->render();
$formResult = $this->formResultFactory->create($formResult);
$formResults = new FormResultCollection();
$formResults->add($formResult);
return ['element' => $formElementData, 'results' => $formResults];
} catch (NoFieldsToRenderException) {
return ['errorHtml' => $this->getInfobox(
$this->getLanguageService()->sL('LLL:EXT:backend/Resources/Private/Language/locallang_alt_doc.xlf:noFieldsEditForm.message'),
$this->getLanguageService()->sL('LLL:EXT:backend/Resources/Private/Language/locallang_alt_doc.xlf:noFieldsEditForm'),
)];
} catch (AccessDeniedException $e) {
return ['errorHtml' => $this->getInfobox(
$e->getMessage(),
$this->getLanguageService()->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.noEditPermission'),
)];
} catch (DatabaseRecordException|DatabaseRecordWorkspaceDeletePlaceholderException $e) {
return ['errorHtml' => $this->getInfobox($e->getMessage())];
}
}
/**
* Redirect back to this controller with additional flags for the JS module.
*/
private function redirectToSelf(array $queryParams, array $additionalParams): ResponseInterface
{
$queryParams = array_merge($queryParams, $additionalParams);
$url = $this->uriBuilder->buildUriFromRoute('record_edit_contextual', $queryParams);
return new RedirectResponse($url, 302);
}
/**
* Resolve the workspace-aware UID for a single record.
* In a workspace, the live UID is replaced with the workspace overlay UID.
*/
private function resolveOverlayUid(string $table, int $uid): int
{
$record = $this->getRecordForEdit($table, $uid);
if (is_array($record)) {
return (int)$record['uid'];
}
return $uid;
}
/**
* Get record for editing, resolving workspace versions.
*
* @return array|false
*/
private function getRecordForEdit(string $table, int $recordId): array|bool
{
$schema = $this->tcaSchemaFactory->get($table);
$reqRecord = BackendUtility::getRecord($table, $recordId, 'uid,pid' . ($schema->isWorkspaceAware() ? ',t3ver_oid' : ''));
if (is_array($reqRecord)) {
if ($this->getBackendUser()->workspace !== 0) {
if ($schema->isWorkspaceAware()) {
if ($reqRecord['t3ver_oid'] > 0 || VersionState::tryFrom($reqRecord['t3ver_state'] ?? 0) === VersionState::NEW_PLACEHOLDER) {
return $reqRecord;
}
$versionRec = BackendUtility::getWorkspaceVersionOfRecord(
$this->getBackendUser()->workspace,
$table,
$reqRecord['uid'],
'uid,pid,t3ver_oid'
);
return is_array($versionRec) ? $versionRec : $reqRecord;
}
return false;
}
return $reqRecord;
}
return false;
}
private function getInfobox(string $message, ?string $title = null): string
{
return '
<div class="callout callout-danger">
<div class="callout-icon">
<span class="icon-emphasized">
' . $this->iconFactory->getIcon('actions-close', IconSize::SMALL)->render() . '
</span>
</div>
<div class="callout-content">
' . ($title ? '<div class="callout-title">' . htmlspecialchars($title) . '</div>' : '') . '
<div class="callout-body">
' . htmlspecialchars($message) . '
</div>
</div>
</div>';
}
private function getBackendUser(): BackendUserAuthentication
{
return $GLOBALS['BE_USER'];
}
private function getLanguageService(): LanguageService
{
return $GLOBALS['LANG'];
}
}
+44
View File
@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
/*
* 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 TYPO3\CMS\Backend\Controller;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Backend\Attribute\AsController;
use TYPO3\CMS\Backend\Template\ModuleTemplateFactory;
/**
* '/empty' routing target returns dummy content.
* @internal This class is a specific Backend controller implementation and is not considered part of the Public TYPO3 API.
*/
#[AsController]
readonly class DummyController
{
public function __construct(protected ModuleTemplateFactory $moduleTemplateFactory) {}
/**
* Return simple dummy content
*/
public function mainAction(ServerRequestInterface $request): ResponseInterface
{
$view = $this->moduleTemplateFactory->create($request);
$view->setTitle('Blank');
$view->getDocHeaderComponent()->disable();
return $view->renderResponse('Dummy/Index');
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,84 @@
<?php
declare(strict_types=1);
/*
* 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 TYPO3\CMS\Backend\Controller;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Backend\Attribute\AsController;
use TYPO3\CMS\Backend\ElementBrowser\ElementBrowserRegistry;
use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
use TYPO3\CMS\Core\Http\HtmlResponse;
/**
* Script class for the Element Browser window.
* @internal This class is a specific Backend controller implementation and is not part of the TYPO3's Core API.
*/
#[AsController]
class ElementBrowserController
{
/**
* The mode determines the main kind of output of the element browser.
*
* There are these options for values:
* - "db" will allow you to browse for pages or records in the page tree for FormEngine select fields
* - "file" will allow you to browse for files in the folder mounts for FormEngine file selections
* - "folder" will allow you to browse for folders in the folder mounts for FormEngine folder selections
* - Other options may be registered via extensions
*/
protected string $mode = '';
public function __construct(protected readonly ElementBrowserRegistry $elementBrowserRegistry) {}
/**
* Injects the request object for the current request or sub-request
* As this controller goes only through the main() method, it is rather simple for now
*
* @param ServerRequestInterface $request the current request
* @return ResponseInterface the response with the content
*/
public function mainAction(ServerRequestInterface $request): ResponseInterface
{
$this->mode = $request->getQueryParams()['mode'] ?? $request->getQueryParams()['mode'] ?? '';
return new HtmlResponse($this->main($request));
}
/**
* Main function, detecting the current mode of the element browser and branching out to internal methods.
*
* @return string HTML content
*/
protected function main(ServerRequestInterface $request)
{
$browser = $this->elementBrowserRegistry->getElementBrowser($this->mode);
if (is_callable([$browser, 'setRequest'])) {
$browser->setRequest($request);
}
$backendUser = $this->getBackendUser();
$modData = $backendUser->getModuleData('browse_links.php', 'ses');
[$modData] = $browser->processSessionData($modData);
$backendUser->pushModuleData('browse_links.php', $modData);
return $browser->render();
}
protected function getBackendUser(): BackendUserAuthentication
{
return $GLOBALS['BE_USER'];
}
}
@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
/*
* 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 TYPO3\CMS\Backend\Controller\Event;
use TYPO3\CMS\Core\View\ViewInterface;
/**
* This event triggers after a page has been rendered.
*
* Listeners may update the page content string with a modified
* version if appropriate.
*/
final class AfterBackendPageRenderEvent
{
public function __construct(private string $content, private readonly ViewInterface $view) {}
public function getContent(): string
{
return $this->content;
}
public function setContent(string $content): void
{
$this->content = $content;
}
public function getView(): ViewInterface
{
return $this->view;
}
}
@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
/*
* 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 TYPO3\CMS\Backend\Controller\Event;
use Psr\Http\Message\ServerRequestInterface;
/**
* Listeners to this event will be able to modify the prepared file storage tree items for the file / folder tree
*/
final class AfterFileStorageTreeItemsPreparedEvent
{
/**
* @param array<int, array<string, mixed>> $items
*/
public function __construct(
private readonly ServerRequestInterface $request,
private array $items
) {}
public function getRequest(): ServerRequestInterface
{
return $this->request;
}
public function getItems(): array
{
return $this->items;
}
public function setItems(array $items): void
{
$this->items = $items;
}
}
@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
/*
* 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 TYPO3\CMS\Backend\Controller\Event;
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Backend\Controller\EditDocumentController;
/**
* Event to listen to after the form engine has been initialized (= all data has been persisted)
*/
final readonly class AfterFormEnginePageInitializedEvent
{
public function __construct(
private EditDocumentController $controller,
private ServerRequestInterface $request
) {}
public function getController(): EditDocumentController
{
return $this->controller;
}
public function getRequest(): ServerRequestInterface
{
return $this->request;
}
}
@@ -0,0 +1,86 @@
<?php
declare(strict_types=1);
/*
* 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 TYPO3\CMS\Backend\Controller\Event;
use TYPO3\CMS\Backend\View\BackendLayout\BackendLayout;
/**
* This event triggers after the LocalizationController (AJAX) has
* selected page columns to be translated. Allows third parties to
* add to or change the columns and content elements withing those
* columns which will be available for localization through the
* "translate" modal in the page module.
*/
final class AfterPageColumnsSelectedForLocalizationEvent
{
public function __construct(
private array $columns,
private array $columnList,
private readonly BackendLayout $backendLayout,
private readonly array $records,
private readonly array $parameters
) {}
/**
* Returns list of columns, indexed by column position number, value is label (either LLL: or hardcoded).
*/
public function getColumns(): array
{
return $this->columns;
}
public function setColumns(array $columns): void
{
$this->columns = $columns;
}
/**
* Returns a list of integer column position numbers used in the BackendLayout.
*/
public function getColumnList(): array
{
return $this->columnList;
}
public function setColumnList(array $columnList): void
{
$this->columnList = $columnList;
}
public function getBackendLayout(): BackendLayout
{
return $this->backendLayout;
}
/**
* Returns an array of records which were used when building the original column
* manifest and column position numbers list.
*/
public function getRecords(): array
{
return $this->records;
}
/**
* Returns request parameters passed to LocalizationController.
*/
public function getParameters(): array
{
return $this->parameters;
}
}
@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
/*
* 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 TYPO3\CMS\Backend\Controller\Event;
use Psr\Http\Message\ServerRequestInterface;
/**
* Listeners to this event will be able to modify the prepared page tree items for the page tree
*/
final class AfterPageTreeItemsPreparedEvent
{
/**
* @param array<int, array<string, mixed>> $items
*/
public function __construct(
private readonly ServerRequestInterface $request,
private array $items
) {}
public function getRequest(): ServerRequestInterface
{
return $this->request;
}
public function getItems(): array
{
return $this->items;
}
public function setItems(array $items): void
{
$this->items = $items;
}
}
@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
/*
* 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 TYPO3\CMS\Backend\Controller\Event;
/**
* Event dispatched after a record is opened for editing in FormEngine.
*
* This event allows extensions to track when records are opened,
* such as maintaining lists of open documents or logging user activity.
*
* One event is dispatched per record being opened.
*
* @internal This event may change until v15 LTS
*/
final readonly class AfterRecordOpenedEvent
{
/**
* @param string $table The database table name
* @param int|string $uid The record UID
* @param array<string, mixed> $record The full database record
*/
public function __construct(
public string $table,
public int|string $uid,
public array $record,
) {}
}
@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
/*
* 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 TYPO3\CMS\Backend\Controller\Event;
final class AfterRecordSummaryForLocalizationEvent
{
public function __construct(
private array $records,
private array $columns
) {}
public function getColumns(): array
{
return $this->columns;
}
public function setColumns(array $columns): void
{
$this->columns = $columns;
}
public function getRecords(): array
{
return $this->records;
}
public function setRecords(array $records): void
{
$this->records = $records;
}
}
@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
/*
* 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 TYPO3\CMS\Backend\Controller\Event;
use TYPO3\CMS\Core\Page\JavaScriptRenderer;
use TYPO3\CMS\Core\Page\PageRenderer;
use TYPO3\CMS\Core\View\ViewInterface;
/**
* This event triggers before a page has been rendered.
*/
final readonly class BeforeBackendPageRenderEvent
{
public function __construct(
public ViewInterface $view,
public JavaScriptRenderer $javaScriptRenderer,
/** @internal */
public PageRenderer $pageRenderer,
) {}
}
@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
/*
* 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 TYPO3\CMS\Backend\Controller\Event;
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Backend\Controller\EditDocumentController;
/**
* Event to listen to before the form engine has been initialized (= before all data will be persisted)
*/
final readonly class BeforeFormEnginePageInitializedEvent
{
public function __construct(
private EditDocumentController $controller,
private ServerRequestInterface $request
) {}
public function getController(): EditDocumentController
{
return $this->controller;
}
public function getRequest(): ServerRequestInterface
{
return $this->request;
}
}
@@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
/*
* 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 TYPO3\CMS\Backend\Controller\Event;
/**
* This event allows extensions to add or remove from the list of allowed link types.
*/
final class ModifyAllowedItemsEvent
{
/**
* @param string[] $allowedItems
* @param array<string, mixed> $currentLinkParts
*/
public function __construct(
private array $allowedItems,
private array $currentLinkParts,
) {}
/**
* @return string[]
*/
public function getAllowedItems(): array
{
return $this->allowedItems;
}
public function addAllowedItem(string $item): self
{
$this->allowedItems[] = $item;
return $this;
}
public function removeAllowedItem(string $new): self
{
$this->allowedItems = array_filter($this->allowedItems, static fn(string $item): bool => $item !== $new);
return $this;
}
/**
* @return array<string, mixed>
*/
public function getCurrentLinkParts(): array
{
return $this->currentLinkParts;
}
}
@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
/*
* 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 TYPO3\CMS\Backend\Controller\Event;
use TYPO3\CMS\Core\Messaging\AbstractMessage;
/**
* Listeners to this event are able to add or change messages for the "Help > About" module.
*/
final class ModifyGenericBackendMessagesEvent
{
private array $messages = [];
public function getMessages(): array
{
return $this->messages;
}
public function addMessage(AbstractMessage $message): void
{
$this->messages[] = $message;
}
public function setMessages(array $messages): void
{
$this->messages = $messages;
}
}
@@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
/*
* 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 TYPO3\CMS\Backend\Controller\Event;
/**
* This event allows extensions to modify the list of link handlers and their configuration before they are invoked.
*/
final class ModifyLinkHandlersEvent
{
/**
* @param array<string, array> $linkHandlers
* @param array<string, mixed> $currentLinkParts
*/
public function __construct(
private array $linkHandlers,
private array $currentLinkParts,
) {}
/**
* @return array<string, array>
*/
public function getLinkHandlers(): array
{
return $this->linkHandlers;
}
/**
* Gets an individual handler by name.
*
* @param string $name The handler name, including trailing period.
* @return array<string, mixed>|null The handler definition, or null if not defined.
*/
public function getLinkHandler(string $name): ?array
{
return $this->linkHandlers[$name] ?? null;
}
/**
* Sets a handler by name, overwriting it if it already exists.
*
* @param string $name The handler name, including trailing period.
* @param array<string, mixed> $handler
* @return $this
*/
public function setLinkHandler(string $name, array $handler): self
{
$this->linkHandlers[$name] = $handler;
return $this;
}
/**
* @return array<string, mixed>
*/
public function getCurrentLinkParts(): array
{
return $this->currentLinkParts;
}
}
@@ -0,0 +1,142 @@
<?php
declare(strict_types=1);
/*
* 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 TYPO3\CMS\Backend\Controller\Event;
use Psr\Http\Message\ServerRequestInterface;
/**
* Listeners to this Event will be able to modify the wizard items of the new content element wizard component
*/
final class ModifyNewContentElementWizardItemsEvent
{
public function __construct(
private array $wizardItems,
private readonly array $pageInfo,
private readonly ?int $colPos,
private readonly int $sys_language,
private readonly int $uid_pid,
private readonly ServerRequestInterface $request,
) {}
public function getWizardItems(): array
{
return $this->wizardItems;
}
public function setWizardItems(array $wizardItems): void
{
$this->wizardItems = $wizardItems;
}
public function hasWizardItem(string $identifier): bool
{
return isset($this->wizardItems[$identifier]);
}
public function getWizardItem(string $identifier): ?array
{
return $this->wizardItems[$identifier] ?? null;
}
/**
* Add a new wizard item with configuration at a defined position.
* Can also be used to relocate existing items and to modify their configuration.
*/
public function setWizardItem(string $identifier, array $configuration, array $position = []): void
{
if (isset($this->wizardItems[$position['before'] ?? ''])
|| isset($this->wizardItems[$position['after'] ?? ''])
) {
// Always unset an existing item if valid positioning is requested
unset($this->wizardItems[$identifier]);
}
// Add item before another item
if (($position['before'] ?? false)
&& ($insertPosition = array_search((string)$position['before'], array_keys($this->wizardItems), true)) !== false
) {
$this->wizardItems = array_slice($this->wizardItems, 0, $insertPosition)
+ [$identifier => $configuration]
+ array_slice($this->wizardItems, $insertPosition);
return;
}
// Add item after another item
if (($position['after'] ?? false)
&& ($insertPosition = array_search((string)$position['after'], array_keys($this->wizardItems), true)) !== false
) {
$this->wizardItems = array_slice($this->wizardItems, 0, $insertPosition + 1)
+ [$identifier => $configuration]
+ array_slice($this->wizardItems, $insertPosition + 1);
return;
}
// By default, add the item at the bottom or might just overwrite configuration of an existing item
$this->wizardItems[$identifier] = $configuration;
}
public function removeWizardItem(string $identifier): bool
{
if (!$this->hasWizardItem($identifier)) {
return false;
}
unset($this->wizardItems[$identifier]);
return true;
}
/**
* Provides information about the current page making use of the wizard.
*/
public function getPageInfo(): array
{
return $this->pageInfo;
}
/**
* Provides information about the column position of the button that triggered the wizard.
*/
public function getColPos(): ?int
{
return $this->colPos;
}
/**
* Provides information about the language used while triggering the wizard.
*/
public function getSysLanguage(): int
{
return $this->sys_language;
}
/**
* Provides information about the element to position the new element after (uid) or into (pid).
*/
public function getUidPid(): int
{
return $this->uid_pid;
}
/**
* Provides the request in the state it was provided to the NewContentElementController::wizardAction() method.
*/
public function getRequest(): ServerRequestInterface
{
return $this->request;
}
}
@@ -0,0 +1,88 @@
<?php
declare(strict_types=1);
/*
* 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 TYPO3\CMS\Backend\Controller\Event;
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Core\Imaging\Icon;
/**
* Event to modify the grouped links as result of NewRecordController
*
* Structure (array):
*
* "content" => [
* "title" => "Content",
* "icon" => "<img...>"
* "items" => [
* "sys_note" => [
* [
* "url" => "...",
* "icon" => "...",
* "label" => "...",
* ],
* ],
* "sys_file_collection" => [
* [
* "icon" => "...",
* "label" => "...",
* "types" => [
* "static" => [
* 'url' => "...",
* 'icon' => "...",
* 'label' => "...",
* ],
* "folder" => [
* 'url' => "...",
* 'icon' => "...",
* 'label' => "...",
* ],
* ],
* ],
* ],
* ],
* ],
* "system" => [
* "title" => "System Records",
* "icon" => "<img...>"
* "items" => [
* "sys_template" => [
* [
* "url" => "...",
* "icon" => "...",
* "label" => "...",
* ],
* ],
* "backend_layout" => [
* [
* "url" => "...",
* "icon" => "...",
* "label" => "...",
* ],
* ],
* ],
* ],
*/
final class ModifyNewRecordCreationLinksEvent
{
public function __construct(
public array $groupedCreationLinks,
public readonly array $pageTS,
public readonly int $pageId,
public readonly ServerRequestInterface $request
) {}
}
@@ -0,0 +1,89 @@
<?php
declare(strict_types=1);
/*
* 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 TYPO3\CMS\Backend\Controller\Event;
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Backend\Template\ModuleTemplate;
/**
* Listeners to this Event will be able to modify the header and footer content of the page module
*/
final class ModifyPageLayoutContentEvent
{
private string $headerContent = '';
private string $footerContent = '';
public function __construct(
private readonly ServerRequestInterface $request,
private readonly ModuleTemplate $moduleTemplate
) {}
public function getRequest(): ServerRequestInterface
{
return $this->request;
}
public function getModuleTemplate(): ModuleTemplate
{
return $this->moduleTemplate;
}
/**
* Set content for the header. Can also be used to e.g. reorder existing content.
* IMPORTANT: This overwrites existing content from previous listeners!
*/
public function setHeaderContent(string $content): void
{
$this->headerContent = $content;
}
/**
* Add additional content to the header
*/
public function addHeaderContent(string $content): void
{
$this->headerContent .= $content;
}
public function getHeaderContent(): string
{
return $this->headerContent;
}
/**
* Set content for the footer. Can also be used to e.g. reorder existing content.
* IMPORTANT: This overwrites existing content from previous listeners!
*/
public function setFooterContent(string $content): void
{
$this->footerContent = $content;
}
/**
* Add additional content to the footer
*/
public function addFooterContent(string $content): void
{
$this->footerContent .= $content;
}
public function getFooterContent(): string
{
return $this->footerContent;
}
}
@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
/*
* 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 TYPO3\CMS\Backend\Controller\Event;
use Psr\Http\Message\ServerRequestInterface;
/**
* Add content above or below the main content of the record list
*/
final class RenderAdditionalContentToRecordListEvent
{
private string $contentAbove = '';
private string $contentBelow = '';
public function __construct(private readonly ServerRequestInterface $request) {}
public function getRequest(): ServerRequestInterface
{
return $this->request;
}
public function addContentAbove(string $contentAbove): void
{
$this->contentAbove .= $contentAbove;
}
public function addContentBelow(string $contentBelow): void
{
$this->contentBelow .= $contentBelow;
}
public function getAdditionalContentAbove(): string
{
return $this->contentAbove;
}
public function getAdditionalContentBelow(): string
{
return $this->contentBelow;
}
}
+340
View File
@@ -0,0 +1,340 @@
<?php
declare(strict_types=1);
/*
* 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 TYPO3\CMS\Backend\Controller\File;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Backend\Attribute\AsController;
use TYPO3\CMS\Backend\Clipboard\Clipboard;
use TYPO3\CMS\Backend\Routing\Exception\RouteNotFoundException;
use TYPO3\CMS\Backend\Routing\UriBuilder;
use TYPO3\CMS\Backend\Utility\BackendUtility;
use TYPO3\CMS\Core\Http\HtmlResponse;
use TYPO3\CMS\Core\Http\JsonResponse;
use TYPO3\CMS\Core\Http\RedirectResponse;
use TYPO3\CMS\Core\Imaging\IconFactory;
use TYPO3\CMS\Core\Imaging\IconSize;
use TYPO3\CMS\Core\Messaging\FlashMessageService;
use TYPO3\CMS\Core\Resource\Enum\DuplicationBehavior;
use TYPO3\CMS\Core\Resource\File;
use TYPO3\CMS\Core\Resource\Folder;
use TYPO3\CMS\Core\Resource\ProcessedFile;
use TYPO3\CMS\Core\Resource\ResourceFactory;
use TYPO3\CMS\Core\Type\ContextualFeedbackSeverity;
use TYPO3\CMS\Core\Utility\File\ExtendedFileUtility;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Gateway for TCE (TYPO3 Core Engine) file-handling through POST forms.
* This script serves as the file administration part of the TYPO3 Core Engine.
* Basically it includes two libraries which are used to manipulate files on the server.
* @internal This class is a specific Backend controller implementation and is not considered part of the Public TYPO3 API.
*/
#[AsController]
class FileController
{
/**
* Array of file-operations.
*
* @var array
*/
protected $file;
/**
* Clipboard operations array
*
* @var array
*/
protected $CB;
/**
* Defines behaviour when uploading files with names that already exist.
*/
protected DuplicationBehavior $overwriteExistingFiles;
/**
* The page where the user should be redirected after everything is done
*
* @var string
*/
protected $redirect;
/**
* The result array from the file processor
*
* @var array
*/
protected $fileData;
public function __construct(
protected readonly ResourceFactory $fileFactory,
protected readonly ExtendedFileUtility $fileProcessor,
protected readonly IconFactory $iconFactory,
protected readonly UriBuilder $uriBuilder,
protected readonly FlashMessageService $flashMessageService
) {}
/**
* Injects the request object for the current request or subrequest
* As this controller goes only through the main() method, it just redirects to the given URL afterwards.
*
* @param ServerRequestInterface $request the current request
* @return ResponseInterface the response with the content
* @throws RouteNotFoundException
*/
public function mainAction(ServerRequestInterface $request): ResponseInterface
{
$this->init($request);
$this->main($request);
BackendUtility::setUpdateSignal('updateFolderTree');
// go and edit the new created file
if ($request->getParsedBody()['edit'] ?? '') {
$file = $this->fileData['newfile'][0];
if ($file !== null) {
$this->redirect = $this->getFileEditRedirect($file) ?? $this->redirect;
}
}
if ($this->redirect) {
return new RedirectResponse(
GeneralUtility::locationHeaderUrl($this->redirect, $request),
303
);
}
// empty response
return new HtmlResponse('');
}
/**
* Handles the actual process from within the ajaxExec function
* therefore, it does exactly the same as the real typo3/tce_file.php.
*/
public function processAjaxRequest(ServerRequestInterface $request): ResponseInterface
{
$this->init($request);
$this->main($request);
$flatResult = [
'hasErrors' => false,
];
foreach ($this->fileData as $action => $results) {
foreach ($results as $result) {
if (is_array($result)) {
foreach ($result as $subResult) {
$flatResult[$action][] = $this->flattenResultDataValue($subResult);
}
} else {
$flatResult[$action][] = $this->flattenResultDataValue($result);
}
}
}
// Used in the FileStorageTree when moving / copying folders, or in the DragUploader
$messages = $this->flashMessageService->getMessageQueueByIdentifier()->getAllMessagesAndFlush();
if (!empty($messages)) {
foreach ($messages as $message) {
$flatResult['messages'][] = [
'title' => $message->getTitle(),
'message' => $message->getMessage(),
'severity' => $message->getSeverity(),
];
if ($message->getSeverity() === ContextualFeedbackSeverity::ERROR) {
$flatResult['hasErrors'] = true;
}
}
}
return new JsonResponse($flatResult, $flatResult['hasErrors'] ? 500 : 200);
}
/**
* Ajax entry point to check if a file exists in a folder
*/
public function fileExistsInFolderAction(ServerRequestInterface $request): ResponseInterface
{
$this->init($request);
$fileName = $request->getParsedBody()['fileName'] ?? $request->getQueryParams()['fileName'] ?? null;
$fileTarget = $request->getParsedBody()['fileTarget'] ?? $request->getQueryParams()['fileTarget'] ?? null;
$fileTargetObject = $this->fileFactory->retrieveFileOrFolderObject($fileTarget);
$processedFileName = $fileTargetObject->getStorage()->sanitizeFileName($fileName, $fileTargetObject);
$result = [];
if ($fileTargetObject->hasFile($processedFileName)) {
$fileInFolder = $fileTargetObject->getStorage()->getFileInFolder($processedFileName, $fileTargetObject);
if ($fileInFolder instanceof File) {
$result = $this->flattenFileResultDataValue($fileInFolder);
}
}
return new JsonResponse($result);
}
/**
* Registering incoming data
*/
protected function init(ServerRequestInterface $request): void
{
// Set the GPvars from outside
$parsedBody = $request->getParsedBody();
$queryParams = $request->getQueryParams();
$this->file = (array)($parsedBody['data'] ?? $queryParams['data'] ?? []);
$redirectUrl = (string)($parsedBody['redirect'] ?? $queryParams['redirect'] ?? '');
if ($this->file === [] || $redirectUrl !== '') {
// This in clipboard mode or when a new folder is created
$this->redirect = GeneralUtility::sanitizeLocalUrl($redirectUrl, $request);
} else {
$mode = key($this->file);
$elementKey = key($this->file[$mode]);
$this->redirect = GeneralUtility::sanitizeLocalUrl($this->file[$mode][$elementKey]['redirect'] ?? '', $request);
}
$this->CB = (array)($parsedBody['CB'] ?? $queryParams['CB'] ?? []);
if (isset($this->file['rename'][0]['conflictMode'])) {
$conflictMode = $this->file['rename'][0]['conflictMode'];
unset($this->file['rename'][0]['conflictMode']);
$this->overwriteExistingFiles = DuplicationBehavior::tryFrom($conflictMode) ?? DuplicationBehavior::getDefaultDuplicationBehaviour();
} else {
$duplicationBehaviorFromRequest = $parsedBody['overwriteExistingFiles'] ?? $queryParams['overwriteExistingFiles'] ?? '';
$this->overwriteExistingFiles = DuplicationBehavior::tryFrom($duplicationBehaviorFromRequest) ?? DuplicationBehavior::getDefaultDuplicationBehaviour();
}
$this->initClipboard($request);
}
/**
* Initialize the Clipboard. This will fetch the data about files to paste/delete if such an action has been sent.
*/
protected function initClipboard(ServerRequestInterface $request): void
{
if ($this->CB !== []) {
$clipObj = GeneralUtility::makeInstance(Clipboard::class);
$clipObj->initializeClipboard($request);
if ($this->CB['paste'] ?? false) {
$clipObj->setCurrentPad((string)($this->CB['pad'] ?? ''));
$this->setPasteCmd($clipObj);
}
if ($this->CB['delete'] ?? false) {
$clipObj->setCurrentPad((string)($this->CB['pad'] ?? ''));
$this->setDeleteCmd($clipObj);
}
}
}
/**
* Performing the file admin action:
* Initializes the objects, setting permissions, sending data to object.
*/
protected function main(ServerRequestInterface $request): void
{
$this->fileProcessor->setActionPermissions();
$this->fileProcessor->setExistingFilesConflictMode($this->overwriteExistingFiles);
$this->fileProcessor->start($this->file, $request->getUploadedFiles());
$this->fileData = $this->fileProcessor->processData();
}
/**
* Gets URI to be used for editing given file (if file extension is defined in textfile_ext)
*
* @param File $file to be edited
* @return string|null URI to be redirected to
* @throws RouteNotFoundException
*/
protected function getFileEditRedirect(File $file): ?string
{
if (!$file->isTextFile()) {
return null;
}
$properties = $file->getProperties();
$urlParameters = [
'target' => $properties['storage'] . ':' . $properties['identifier'],
];
if ($this->redirect) {
$urlParameters['returnUrl'] = $this->redirect;
}
try {
return (string)$this->uriBuilder->buildUriFromRoute('file_edit', $urlParameters);
} catch (RouteNotFoundException $exception) {
// no route for editing files available
return '';
}
}
protected function flattenFileResultDataValue(File $result): array
{
$thumbUrl = $result->isImage()
? ($result->process(ProcessedFile::CONTEXT_IMAGEPREVIEW, [])->getPublicUrl() ?? '')
: '';
return array_merge(
$result->toArray(),
[
'date' => BackendUtility::date($result->getModificationTime()),
'icon' => $this->iconFactory->getIconForFileExtension($result->getExtension(), IconSize::SMALL)->render(),
'thumbUrl' => $thumbUrl,
'path' => $result->getParentFolder()->getReadablePath(),
]
);
}
/**
* Flatten result value from FileProcessor
*
* The value can be a File, Folder or boolean
*
* @param bool|File|Folder|ProcessedFile $result
*
* @return bool|string|array
*/
protected function flattenResultDataValue($result)
{
if ($result instanceof File) {
$result = $this->flattenFileResultDataValue($result);
} elseif ($result instanceof Folder) {
$result = $result->getIdentifier();
}
return $result;
}
/**
* Applies the proper paste configuration to $this->file
*/
protected function setPasteCmd(Clipboard $clipboard): void
{
$target = explode('|', (string)$this->CB['paste'])[1] ?? '';
$mode = $clipboard->currentMode() === 'copy' ? 'copy' : 'move';
// Traverse elements and make CMD array
foreach ($clipboard->elFromTable('_FILE') as $key => $path) {
$this->file[$mode][] = ['data' => $path, 'target' => $target];
if ($mode === 'move') {
$clipboard->removeElement($key);
}
}
$clipboard->endClipboard();
}
/**
* Applies the proper delete configuration to $this->file
*/
protected function setDeleteCmd(Clipboard $clipObj): void
{
// Traverse elements and make CMD array
foreach ($clipObj->elFromTable('_FILE') as $key => $path) {
$this->file['delete'][] = ['data' => $path];
$clipObj->removeElement($key);
}
$clipObj->endClipboard();
}
}
@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
/*
* 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 TYPO3\CMS\Backend\Controller\File;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Log\LoggerInterface;
use TYPO3\CMS\Backend\Attribute\AsController;
use TYPO3\CMS\Core\Http\HtmlResponse;
use TYPO3\CMS\Core\Http\RedirectResponse;
use TYPO3\CMS\Core\Resource\Service\ImageProcessingService;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* @internal This class is a specific Backend controller implementation and is not considered part of the Public TYPO3 API.
*/
#[AsController]
readonly class ImageProcessController
{
public function __construct(
private ImageProcessingService $imageProcessingService,
private LoggerInterface $logger,
) {}
public function process(ServerRequestInterface $request): ResponseInterface
{
$processedFileId = (int)($request->getQueryParams()['id'] ?? 0);
try {
$processedFile = $this->imageProcessingService->process($processedFileId);
if (!$processedFile->getOriginalFile()->checkActionPermission('read')) {
return new HtmlResponse('', 403);
}
return new RedirectResponse(
GeneralUtility::locationHeaderUrl($processedFile->getPublicUrl() ?? '', $request)
);
} catch (\Throwable $e) {
// Fatal error occurred, which will be responded as 404
$this->logger->error('Processing of file with id {processed_file} failed', ['processed_file' => $processedFileId, 'exception' => $e]);
}
return new HtmlResponse('', 404);
}
}
@@ -0,0 +1,235 @@
<?php
declare(strict_types=1);
/*
* 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 TYPO3\CMS\Backend\Controller\FileStorage;
use Psr\EventDispatcher\EventDispatcherInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Backend\Attribute\AsController;
use TYPO3\CMS\Backend\Controller\Event\AfterFileStorageTreeItemsPreparedEvent;
use TYPO3\CMS\Backend\Dto\Tree\FileTreeItem;
use TYPO3\CMS\Backend\Dto\Tree\Label\Label;
use TYPO3\CMS\Backend\Dto\Tree\TreeItem;
use TYPO3\CMS\Backend\Tree\FileStorageTreeProvider;
use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
use TYPO3\CMS\Core\Http\JsonResponse;
use TYPO3\CMS\Core\Imaging\IconFactory;
use TYPO3\CMS\Core\Imaging\IconSize;
use TYPO3\CMS\Core\Localization\LanguageService;
use TYPO3\CMS\Core\Resource\Exception\FolderDoesNotExistException;
use TYPO3\CMS\Core\Resource\Exception\InsufficientFolderAccessPermissionsException;
use TYPO3\CMS\Core\Resource\Folder;
use TYPO3\CMS\Core\Resource\FolderInterface;
use TYPO3\CMS\Core\Resource\ResourceFactory;
/**
* Controller providing data to the file storage tree.
*
* @internal This class is a specific Backend controller implementation and is not considered part of the Public TYPO3 API.
*/
#[AsController]
readonly class TreeController
{
public function __construct(
protected IconFactory $iconFactory,
protected FileStorageTreeProvider $treeProvider,
protected ResourceFactory $resourceFactory,
protected EventDispatcherInterface $eventDispatcher,
) {}
/**
* Loads data for the first time, or when expanding a folder.
*/
public function fetchDataAction(ServerRequestInterface $request): ResponseInterface
{
$parentIdentifier = $request->getQueryParams()['parent'] ?? null;
if ($parentIdentifier) {
$currentDepth = (int)($request->getQueryParams()['depth'] ?? 1);
$parentIdentifier = rawurldecode($parentIdentifier);
$folder = $this->resourceFactory->getFolderObjectFromCombinedIdentifier($parentIdentifier);
$items = $this->treeProvider->getSubfolders($folder, $currentDepth + 1);
} else {
$items = $this->treeProvider->getRootNodes($this->getBackendUser());
}
return new JsonResponse($this->getPreparedItemsForOutput($request, $items));
}
/**
* Returns JSON representing page rootline
*/
public function fetchRootlineAction(ServerRequestInterface $request): ResponseInterface
{
$identifier = (string)($request->getQueryParams()['identifier'] ?? '');
if ($identifier === '') {
return new JsonResponse(null, 400);
}
try {
$folder = $this->resourceFactory->getFolderObjectFromCombinedIdentifier($identifier);
} catch (InsufficientFolderAccessPermissionsException) {
return new JsonResponse(null, 403);
} catch (FolderDoesNotExistException) {
return new JsonResponse(null, 404);
}
$rootline = [];
while (true) {
$identifier = $folder->getCombinedIdentifier();
$rootline[] = $identifier;
try {
$parent = $folder->getParentFolder();
} catch (InsufficientFolderAccessPermissionsException) {
break;
}
if ($parent->getCombinedIdentifier() === $identifier) {
// parent folder of root folder is the root folder => break
break;
}
$folder = $parent;
}
return new JsonResponse([
'rootline' => array_reverse($rootline),
]);
}
/**
* Used when the search / filter is used.
*
* @throws \Exception
*/
public function filterDataAction(ServerRequestInterface $request): ResponseInterface
{
$search = $request->getQueryParams()['q'] ?? '';
$foundFolders = $this->treeProvider->getFilteredTree($this->getBackendUser(), $search);
$items = [];
foreach ($foundFolders as $folder) {
if (!$folder instanceof Folder) {
continue;
}
$storage = $folder->getStorage();
$itemsInRootLine = [];
// Go back the root folder structure until the root folder
$nextFolder = $folder;
$isParent = false;
do {
$itemsInRootLine[$nextFolder->getCombinedIdentifier()] = array_merge(
$this->treeProvider->prepareFolderInformation($nextFolder),
[
'expanded' => $isParent,
'loaded' => true,
]
);
$isParent = true;
try {
$nextFolder = $nextFolder->getParentFolder();
} catch (InsufficientFolderAccessPermissionsException) {
$nextFolder = null;
}
} while ($nextFolder instanceof FolderInterface && $nextFolder->getIdentifier() !== '/');
// Add the storage / sys_filemount itself
$storageData = $this->treeProvider->prepareFolderInformation(
$storage->getRootLevelFolder(true),
$storage->getName()
);
$storageData = array_merge($storageData, [
'depth' => 0,
'expanded' => true,
]);
$itemsInRootLine[$storage->getUid() . ':/'] = $storageData;
$itemsInRootLine = array_reverse($itemsInRootLine);
$depth = 0;
foreach ($itemsInRootLine as $k => $itm) {
$itm['depth'] = $depth++;
$items[$k] = $itm;
}
}
ksort($items);
$items = array_values($items);
return new JsonResponse($this->getPreparedItemsForOutput($request, $items));
}
/**
* Adds information for the JSON result to be rendered. Additionally, dispatches event for modification.
*/
protected function getPreparedItemsForOutput(ServerRequestInterface $request, array $items): array
{
foreach ($items as &$item) {
$folder = $item['resource'];
$isStorage = $item['recordType'] !== 'sys_file';
$item['resourceType'] = $isStorage ? 'storage' : 'folder';
if ($isStorage && !$folder->getStorage()->isOnline()) {
$item['name'] .= ' (' . $this->getLanguageService()->translate('is_offline', 'core.db.sys_file_storage') . ')';
}
$icon = $this->iconFactory->getIconForResource($folder, IconSize::SMALL, null, $isStorage ? ['mount-root' => true] : []);
$item['icon'] = $icon->getIdentifier();
$item['overlayIcon'] = $icon->getOverlayIcon() ? $icon->getOverlayIcon()->getIdentifier() : '';
$tsConfigLabels = $this->getBackendUser()->getTSConfig()['options.']['folderTree.']['label.'] ?? [];
if (trim($tsConfigLabels[$folder->getCombinedIdentifier() . '.']['label'] ?? '') !== '') {
$item['labels'][] = new Label(
label: $this->getLanguageService()->sL($tsConfigLabels[$folder->getCombinedIdentifier() . '.']['label']),
color: (string)($tsConfigLabels[$folder->getCombinedIdentifier() . '.']['color'] ?? '#ff8722'),
);
}
}
return array_map(
static function (array $item): FileTreeItem {
return new FileTreeItem(
item: new TreeItem(
identifier: $item['identifier'],
parentIdentifier: (string)($item['parentIdentifier'] ?? ''),
recordType: (string)($item['recordType'] ?? ''),
name: (string)($item['name'] ?? ''),
prefix: (string)($item['prefix'] ?? ''),
suffix: (string)($item['suffix'] ?? ''),
tooltip: (string)($item['tooltip'] ?? ''),
depth: (int)($item['depth'] ?? 0),
hasChildren: (bool)($item['hasChildren'] ?? false),
loaded: (bool)($item['loaded'] ?? false),
icon: $item['icon'],
overlayIcon: $item['overlayIcon'],
statusInformation: (array)($item['statusInformation'] ?? []),
labels: (array)($item['labels'] ?? []),
),
pathIdentifier: (string)($item['pathIdentifier'] ?? ''),
storage: (int)($item['storage'] ?? 0),
resourceType: $item['resourceType'],
);
},
$this->eventDispatcher->dispatch(
new AfterFileStorageTreeItemsPreparedEvent($request, $items)
)->getItems()
);
}
protected function getBackendUser(): BackendUserAuthentication
{
return $GLOBALS['BE_USER'];
}
protected function getLanguageService(): LanguageService
{
return $GLOBALS['LANG'];
}
}
@@ -0,0 +1,513 @@
<?php
declare(strict_types=1);
/*
* 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 TYPO3\CMS\Backend\Controller;
use Psr\Http\Message\ResponseFactoryInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\StreamFactoryInterface;
use TYPO3\CMS\Backend\Attribute\AsController;
use TYPO3\CMS\Backend\Form\FormDataCompiler;
use TYPO3\CMS\Backend\Form\FormDataGroup\TcaDatabaseRecord;
use TYPO3\CMS\Backend\Form\InlineStackProcessor;
use TYPO3\CMS\Backend\Form\NodeFactory;
use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
use TYPO3\CMS\Core\Crypto\HashService;
use TYPO3\CMS\Core\DataHandling\DataHandler;
use TYPO3\CMS\Core\Messaging\FlashMessageService;
use TYPO3\CMS\Core\Page\JavaScriptItems;
use TYPO3\CMS\Core\Schema\TcaSchemaFactory;
use TYPO3\CMS\Core\Type\ContextualFeedbackSeverity;
use TYPO3\CMS\Core\Utility\ArrayUtility;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Core\Utility\MathUtility;
/**
* Handle FormEngine files ajax calls
*/
#[AsController]
readonly class FormFilesAjaxController extends AbstractFormEngineAjaxController
{
private const string FILE_REFERENCE_TABLE = 'sys_file_reference';
public function __construct(
private ResponseFactoryInterface $responseFactory,
private StreamFactoryInterface $streamFactory,
private FormDataCompiler $formDataCompiler,
private HashService $hashService,
private NodeFactory $nodeFactory,
private InlineStackProcessor $inlineStackProcessor,
private TcaSchemaFactory $tcaSchemaFactory,
private FlashMessageService $flashMessageService,
) {}
/**
* Create a new file reference via AJAX.
*/
public function createAction(ServerRequestInterface $request): ResponseInterface
{
$arguments = $request->getParsedBody()['ajax'];
$parentConfig = $this->extractSignedParentConfigFromRequest((string)($arguments['context'] ?? ''));
$domObjectId = (string)($arguments[0] ?? '');
$inlineFirstPid = $this->getInlineFirstPidFromDomObjectId($domObjectId);
if (!MathUtility::canBeInterpretedAsInteger($inlineFirstPid) && !str_starts_with((string)$inlineFirstPid, 'NEW')) {
throw new \RuntimeException(
'inlineFirstPid should either be an integer or a "NEW..." string',
1664440476
);
}
$fileId = null;
if (isset($arguments[1]) && MathUtility::canBeInterpretedAsInteger($arguments[1])) {
$fileId = (int)$arguments[1];
}
$inlineStructure = $this->inlineStackProcessor->getStructureFromString($domObjectId, $this->tcaSchemaFactory->all());
$inlineStructure = $this->inlineStackProcessor->addAjaxConfigurationToStructure($inlineStructure, $parentConfig);
$inlineTopMostParent = $this->inlineStackProcessor->getStructureLevelFromStructure($inlineStructure, 0);
$inlineParent = $this->inlineStackProcessor->getStructureLevelFromStructure($inlineStructure, -1);
$fileReference = $this->inlineStackProcessor->getUnstableStructureFromStructure($inlineStructure);
if (isset($fileReference['uid']) && MathUtility::canBeInterpretedAsInteger($fileReference['uid'])) {
// If uid comes in, it is the id of the record neighbor record "create after"
$fileReferenceVanillaUid = -1 * abs((int)$fileReference['uid']);
} else {
// Else inline first Pid is the storage pid of new inline records
$fileReferenceVanillaUid = $inlineFirstPid;
}
$formDataCompilerInput = [
'request' => $request,
'command' => 'new',
'tableName' => self::FILE_REFERENCE_TABLE,
'vanillaUid' => $fileReferenceVanillaUid,
'isInlineChild' => true,
'inlineStructure' => $inlineStructure,
'inlineFirstPid' => $inlineFirstPid,
'inlineParentUid' => $inlineParent['uid'],
'inlineParentTableName' => $inlineParent['table'],
'inlineParentFieldName' => $inlineParent['field'],
'inlineParentConfig' => $parentConfig,
'inlineTopMostParentUid' => $inlineTopMostParent['uid'],
'inlineTopMostParentTableName' => $inlineTopMostParent['table'],
'inlineTopMostParentFieldName' => $inlineTopMostParent['field'],
];
if ($fileId) {
$formDataCompilerInput['inlineChildChildUid'] = $fileId;
}
$fileReferenceData = $this->formDataCompiler->compile($formDataCompilerInput, GeneralUtility::makeInstance(TcaDatabaseRecord::class));
$fileReferenceData['inlineParentUid'] = $inlineParent['uid'];
$fileReferenceData['renderType'] = 'fileReferenceContainer';
return $this->jsonResponse(
$this->mergeFileReferenceResultIntoJsonResult(
[
'data' => '',
'stylesheetFiles' => [],
'scriptItems' => new JavaScriptItems(),
'compilerInput' => [
'uid' => $fileReferenceData['databaseRow']['uid'],
'childChildUid' => $fileId,
],
],
$this->nodeFactory->create($fileReferenceData)->render()
)
);
}
/**
* Show the details of a file reference
*/
public function detailsAction(ServerRequestInterface $request): ResponseInterface
{
$arguments = $request->getParsedBody()['ajax'] ?? $request->getQueryParams()['ajax'];
$domObjectId = (string)($arguments[0] ?? '');
$inlineFirstPid = $this->getInlineFirstPidFromDomObjectId($domObjectId);
$parentConfig = $this->extractSignedParentConfigFromRequest((string)($arguments['context'] ?? ''));
$inlineStructure = $this->inlineStackProcessor->getStructureFromString($domObjectId, $this->tcaSchemaFactory->all());
$inlineStructure = $this->inlineStackProcessor->addAjaxConfigurationToStructure($inlineStructure, $parentConfig);
$inlineParent = $this->inlineStackProcessor->getStructureLevelFromStructure($inlineStructure, -1);
$fileReference = $this->inlineStackProcessor->getUnstableStructureFromStructure($inlineStructure);
$parentFieldName = $inlineParent['field'];
// Set flag in config so that only the fields are rendered
// @todo: Solve differently / rename / whatever
$parentConfig['renderFieldsOnly'] = true;
$parentData = [
'processedTca' => [
'columns' => [
$parentFieldName => [
'config' => $parentConfig,
],
],
],
'uid' => $inlineParent['uid'],
'tableName' => $inlineParent['table'],
'inlineFirstPid' => $inlineFirstPid,
'returnUrl' => $parentConfig['originalReturnUrl'],
];
$fileReferenceData = $this->compileFileReference($request, $parentData, $parentFieldName, (int)$fileReference['uid'], $inlineStructure);
$fileReferenceData['inlineParentUid'] = (int)$inlineParent['uid'];
$fileReferenceData['renderType'] = 'fileReferenceContainer';
return $this->jsonResponse(
$this->mergeFileReferenceResultIntoJsonResult(
[
'data' => '',
'stylesheetFiles' => [],
'scriptItems' => new JavaScriptItems(),
],
$this->nodeFactory->create($fileReferenceData)->render()
)
);
}
/**
* Adds localizations or synchronizes the locations of all file references.
*/
public function synchronizeLocalizeAction(ServerRequestInterface $request): ResponseInterface
{
$arguments = $request->getParsedBody()['ajax'];
$domObjectId = (string)($arguments[0] ?? '');
$type = $arguments[1] ?? null;
$parentConfig = $this->extractSignedParentConfigFromRequest((string)($arguments['context'] ?? ''));
$inlineStructure = $this->inlineStackProcessor->getStructureFromString($domObjectId, $this->tcaSchemaFactory->all());
$inlineStructure = $this->inlineStackProcessor->addAjaxConfigurationToStructure($inlineStructure, $parentConfig);
$inlineFirstPid = $this->getInlineFirstPidFromDomObjectId($domObjectId);
$jsonArray = [
'data' => '',
'stylesheetFiles' => [],
'scriptItems' => new JavaScriptItems(),
'compilerInput' => [
'localize' => [],
],
];
if ($type === 'localize' || $type === 'synchronize' || MathUtility::canBeInterpretedAsInteger($type)) {
// Parent, this table embeds the sys_file_reference table
$inlineParent = $this->inlineStackProcessor->getStructureLevelFromStructure($inlineStructure, -1);
$parentFieldName = $inlineParent['field'];
$processedTca = $GLOBALS['TCA'][$inlineParent['table']];
$processedTca['columns'][$parentFieldName]['config'] = $parentConfig;
$formDataCompilerInputForParent = [
'request' => $request,
'vanillaUid' => (int)$inlineParent['uid'],
'command' => 'edit',
'tableName' => $inlineParent['table'],
'processedTca' => $processedTca,
'inlineFirstPid' => $inlineFirstPid,
'columnsToProcess' => [
$parentFieldName,
],
// @todo: still needed? NO!
'inlineStructure' => $inlineStructure,
// Do not compile existing file references, we don't need them now
'inlineCompileExistingChildren' => false,
];
// Full TcaDatabaseRecord is required here to have the list of connected uids $oldItemList
$parentData = $this->formDataCompiler->compile($formDataCompilerInputForParent, GeneralUtility::makeInstance(TcaDatabaseRecord::class));
$parentLanguageField = $parentData['processedTca']['ctrl']['languageField'];
$parentLanguage = $parentData['databaseRow'][$parentLanguageField];
$oldItemList = $parentData['databaseRow'][$parentFieldName];
// DataHandler cannot handle arrays as field value
if (is_array($parentLanguage)) {
$parentLanguage = implode(',', $parentLanguage);
}
$cmd = [];
// Localize a single file reference from default language of the inlineParent element
if (MathUtility::canBeInterpretedAsInteger($type)) {
$cmd[$inlineParent['table']][$inlineParent['uid']]['inlineLocalizeSynchronize'] = [
'field' => $inlineParent['field'],
'language' => $parentLanguage,
'ids' => [$type],
];
} else {
// Either localize or synchronize all file references from default language of the inlineParent element
$cmd[$inlineParent['table']][$inlineParent['uid']]['inlineLocalizeSynchronize'] = [
'field' => $inlineParent['field'],
'language' => $parentLanguage,
'action' => $type,
];
}
$tce = GeneralUtility::makeInstance(DataHandler::class);
$tce->start([], $cmd);
$tce->process_cmdmap();
$oldItems = $this->getFileReferenceUids((string)$oldItemList);
$newItemList = (string)($tce->registerDBList[$inlineParent['table']][$inlineParent['uid']][$parentFieldName] ?? '');
$newItems = $this->getFileReferenceUids($newItemList);
// Render error messages from DataHandler
$tce->printLogErrorMessages();
$messages = $this->flashMessageService->getMessageQueueByIdentifier()->getAllMessagesAndFlush();
if (!empty($messages)) {
foreach ($messages as $message) {
$jsonArray['messages'][] = [
'title' => $message->getTitle(),
'message' => $message->getMessage(),
'severity' => $message->getSeverity(),
];
if ($message->getSeverity() === ContextualFeedbackSeverity::ERROR) {
$jsonArray['hasErrors'] = true;
}
}
}
// Set the items that should be removed in the forms view:
$removedItems = array_diff($oldItems, $newItems);
$jsonArray['compilerInput']['delete'] = $removedItems;
$localizedItems = array_diff($newItems, $oldItems);
foreach ($localizedItems as $i => $localizedFileReferenceUid) {
$fileReferenceData = $this->compileFileReference($request, $parentData, $parentFieldName, (int)$localizedFileReferenceUid, $inlineStructure);
$fileReferenceData['inlineParentUid'] = (int)$inlineParent['uid'];
$fileReferenceData['renderType'] = 'fileReferenceContainer';
$jsonArray = $this->mergeFileReferenceResultIntoJsonResult(
$jsonArray,
$this->nodeFactory->create($fileReferenceData)->render()
);
// Get the name of the field used as foreign selector (if any):
$selectedValue = $fileReferenceData['databaseRow']['uid_local'];
if (is_array($selectedValue)) {
$selectedValue = $selectedValue[0];
}
$jsonArray['compilerInput']['localize'][$i] = [
'uid' => $localizedFileReferenceUid,
'selectedValue' => $selectedValue,
];
// Remove possible virtual records in the form which showed that a file reference could be
// localized:
$transOrigPointerFieldName = $fileReferenceData['processedTca']['ctrl']['transOrigPointerField'];
if (isset($fileReferenceData['databaseRow'][$transOrigPointerFieldName]) && $fileReferenceData['databaseRow'][$transOrigPointerFieldName]) {
$transOrigPointerFieldValue = $fileReferenceData['databaseRow'][$transOrigPointerFieldName];
if (is_array($transOrigPointerFieldValue)) {
$transOrigPointerFieldValue = $transOrigPointerFieldValue[0];
if (is_array($transOrigPointerFieldValue) && ($transOrigPointerFieldValue['uid'] ?? false)) {
$transOrigPointerFieldValue = $transOrigPointerFieldValue['uid'];
}
}
$jsonArray['compilerInput']['localize'][$i]['remove'] = $transOrigPointerFieldValue;
}
}
}
return $this->jsonResponse($jsonArray);
}
/**
* Store status of file references' expand / collapse state in backend user UC.
*/
public function expandOrCollapseAction(ServerRequestInterface $request): ResponseInterface
{
[$domObjectId, $expand, $collapse] = $request->getParsedBody()['ajax'];
$inlineStructure = $this->inlineStackProcessor->getStructureFromString($domObjectId, $this->tcaSchemaFactory->all());
$currentTable = $this->inlineStackProcessor->getUnstableStructureFromStructure($inlineStructure)['table'];
$top = $this->inlineStackProcessor->getStructureLevelFromStructure($inlineStructure, 0);
$stateArray = $this->getReferenceExpandCollapseStateArray();
// Only do some action if the top record and the current record were saved before
if (MathUtility::canBeInterpretedAsInteger($top['uid'])) {
// Set records to be expanded
foreach (GeneralUtility::trimExplode(',', $expand) as $uid) {
$stateArray[$top['table']][$top['uid']][$currentTable][] = $uid;
}
// Set records to be collapsed
foreach (GeneralUtility::trimExplode(',', $collapse) as $uid) {
$stateArray[$top['table']][$top['uid']][$currentTable] = $this->removeFromArray(
$uid,
$stateArray[$top['table']][$top['uid']][$currentTable]
);
}
// Save states back to database
if (is_array($stateArray[$top['table']][$top['uid']][$currentTable] ?? false)) {
$stateArray[$top['table']][$top['uid']][$currentTable] = array_unique($stateArray[$top['table']][$top['uid']][$currentTable]);
$backendUser = $this->getBackendUserAuthentication();
$backendUser->uc['inlineView'] = json_encode($stateArray);
$backendUser->writeUC();
}
}
return $this->jsonResponse();
}
protected function compileFileReference(ServerRequestInterface $request, array $parentData, $parentFieldName, $fileReferenceUid, array $inlineStructure): array
{
$inlineTopMostParent = $this->inlineStackProcessor->getStructureLevelFromStructure($inlineStructure, 0);
return $this->formDataCompiler
->compile(
[
'request' => $request,
'command' => 'edit',
'tableName' => self::FILE_REFERENCE_TABLE,
'vanillaUid' => (int)$fileReferenceUid,
'returnUrl' => $parentData['returnUrl'],
'isInlineChild' => true,
'inlineStructure' => $inlineStructure,
'inlineFirstPid' => $parentData['inlineFirstPid'],
'inlineParentConfig' => $parentData['processedTca']['columns'][$parentFieldName]['config'],
'isInlineAjaxOpeningContext' => true,
'inlineParentUid' => $parentData['databaseRow']['uid'] ?? $parentData['uid'],
'inlineParentTableName' => $parentData['tableName'],
'inlineParentFieldName' => $parentFieldName,
'inlineTopMostParentUid' => $inlineTopMostParent['uid'],
'inlineTopMostParentTableName' => $inlineTopMostParent['table'],
'inlineTopMostParentFieldName' => $inlineTopMostParent['field'],
],
GeneralUtility::makeInstance(TcaDatabaseRecord::class)
);
}
/**
* Merge compiled file reference data into the json result array.
*/
protected function mergeFileReferenceResultIntoJsonResult(array $jsonResult, array $fileReferenceData): array
{
/** @var JavaScriptItems $scriptItems */
$scriptItems = $jsonResult['scriptItems'];
$jsonResult['data'] .= $fileReferenceData['html'];
$jsonResult['stylesheetFiles'] = [];
foreach ($fileReferenceData['stylesheetFiles'] as $stylesheetFile) {
$jsonResult['stylesheetFiles'][] = $this->getRelativePathToStylesheetFile($stylesheetFile);
}
if (!empty($fileReferenceData['inlineData'])) {
$jsonResult['inlineData'] = $fileReferenceData['inlineData'];
}
if (!empty($fileReferenceData['additionalInlineLanguageLabelFiles'])) {
$labels = [];
foreach ($fileReferenceData['additionalInlineLanguageLabelFiles'] as $additionalInlineLanguageLabelFile) {
ArrayUtility::mergeRecursiveWithOverrule(
$labels,
$this->getLabelsFromLocalizationFile($additionalInlineLanguageLabelFile)
);
}
$scriptItems->addGlobalAssignment(['TYPO3' => ['lang' => $labels]]);
}
$this->addJavaScriptModulesToJavaScriptItems($fileReferenceData['javaScriptModules'] ?? [], $scriptItems);
return $jsonResult;
}
/**
* Gets an array with the uids of file references out of a list of items.
*/
protected function getFileReferenceUids(string $itemList): array
{
$itemArray = GeneralUtility::trimExplode(',', $itemList, true);
// Perform modification of the selected items array:
foreach ($itemArray as &$value) {
$parts = explode('|', $value, 2);
$value = $parts[0];
}
unset($value);
return $itemArray;
}
/**
* Get expand / collapse state of inline items
*/
protected function getReferenceExpandCollapseStateArray(): array
{
$backendUser = $this->getBackendUserAuthentication();
if (empty($backendUser->uc['inlineView'])) {
return [];
}
$state = json_decode($backendUser->uc['inlineView'], true);
if (!is_array($state)) {
$state = [];
}
return $state;
}
/**
* Remove an element from an array.
*/
protected function removeFromArray(mixed $needle, array $haystack, bool $strict = false): array
{
$pos = array_search($needle, $haystack, $strict);
if ($pos !== false) {
unset($haystack[$pos]);
}
return $haystack;
}
/**
* Get inlineFirstPid from a given objectId string
*/
protected function getInlineFirstPidFromDomObjectId(string $domObjectId): int|string|null
{
// Substitute FlexForm addition and make parsing a bit easier
$domObjectId = str_replace('---', ':', $domObjectId);
// The starting pattern of an object identifier (e.g. "data-<firstPidValue>-<anything>)
$pattern = '/^data-(.+?)-(.+)$/';
if (preg_match($pattern, $domObjectId, $match)) {
return $match[1];
}
return null;
}
/**
* Validates the config that is transferred over the wire to provide the
* correct TCA config for the parent table
*/
protected function extractSignedParentConfigFromRequest(string $contextString): array
{
if ($contextString === '') {
throw new \RuntimeException('Empty context string given', 1664486783);
}
$context = json_decode($contextString, true);
if (empty($context['config'])) {
throw new \RuntimeException('Empty context config section given', 1664486790);
}
if (!hash_equals($this->hashService->hmac((string)$context['config'], 'FilesContext'), (string)$context['hmac'])) {
throw new \RuntimeException('Hash does not validate', 1664486791);
}
return json_decode($context['config'], true);
}
protected function jsonResponse(array $json = []): ResponseInterface
{
return $this->responseFactory->createResponse()
->withHeader('Content-Type', 'application/json; charset=utf-8')
->withBody($this->streamFactory->createStream((string)json_encode($json)));
}
protected function getBackendUserAuthentication(): BackendUserAuthentication
{
return $GLOBALS['BE_USER'];
}
}
@@ -0,0 +1,165 @@
<?php
declare(strict_types=1);
/*
* 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 TYPO3\CMS\Backend\Controller;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Backend\Attribute\AsController;
use TYPO3\CMS\Backend\Form\Behavior\UpdateValueOnFieldChange;
use TYPO3\CMS\Backend\Form\FormDataCompiler;
use TYPO3\CMS\Backend\Form\FormDataGroup\TcaDatabaseRecord;
use TYPO3\CMS\Backend\Form\NodeFactory;
use TYPO3\CMS\Core\Http\JsonResponse;
use TYPO3\CMS\Core\Page\JavaScriptItems;
use TYPO3\CMS\Core\Utility\ArrayUtility;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Core\Utility\StringUtility;
/**
* Handle FormEngine flex field ajax calls
*/
#[AsController]
readonly class FormFlexAjaxController extends AbstractFormEngineAjaxController
{
public function __construct(
private FormDataCompiler $formDataCompiler,
private NodeFactory $nodeFactory,
) {}
/**
* Render a single flex form section container to add it to the DOM
*/
public function containerAdd(ServerRequestInterface $request): ResponseInterface
{
$queryParameters = $request->getParsedBody();
$vanillaUid = (int)$queryParameters['vanillaUid'];
$databaseRowUid = $queryParameters['databaseRowUid'];
$command = $queryParameters['command'];
$tableName = $queryParameters['tableName'];
$fieldName = $queryParameters['fieldName'];
$recordTypeValue = $queryParameters['recordTypeValue'];
$flexFormSheetName = $queryParameters['flexFormSheetName'];
$flexFormFieldName = $queryParameters['flexFormFieldName'];
$flexFormContainerName = $queryParameters['flexFormContainerName'];
// Prepare TCA and data values for a new section container using data providers
// @todo Replace with a mutable schema
$processedTca = $GLOBALS['TCA'][$tableName];
// Get a new unique id for this container.
$flexFormContainerIdentifier = StringUtility::getUniqueId();
$flexSectionContainerPreparation = [
'flexFormSheetName' => $flexFormSheetName,
'flexFormFieldName' => $flexFormFieldName,
'flexFormContainerName' => $flexFormContainerName,
'flexFormContainerIdentifier' => $flexFormContainerIdentifier,
];
$formDataCompilerInput = [
'request' => $request,
'tableName' => $tableName,
'vanillaUid' => (int)$vanillaUid,
'command' => $command,
'recordTypeValue' => $recordTypeValue,
'processedTca' => $processedTca,
'flexSectionContainerPreparation' => $flexSectionContainerPreparation,
];
// A new container on a new record needs the 'NEW123' uid here, see comment
// in DatabaseUniqueUidNewRow for more information on that.
// @todo: Resolve, maybe with a redefinition of vanillaUid to transport the information more clean through this var?
// @see issue #80100 for a series of changes in this area
if ($command === 'new') {
$formDataCompilerInput['databaseRow']['uid'] = $databaseRowUid;
}
$formData = $this->formDataCompiler->compile($formDataCompilerInput, GeneralUtility::makeInstance(TcaDatabaseRecord::class));
$dataStructure = $formData['processedTca']['columns'][$fieldName]['config']['ds'];
$dataStructureIdentifier = $formData['processedTca']['columns'][$fieldName]['config']['dataStructureIdentifier'];
$formData['fieldName'] = $fieldName;
$formData['flexFormDataStructureArray'] = $dataStructure['sheets'][$flexFormSheetName]['ROOT']['el'][$flexFormFieldName]['children'][$flexFormContainerIdentifier];
$formData['flexFormDataStructureIdentifier'] = $dataStructureIdentifier;
$formData['flexFormFieldName'] = $flexFormFieldName;
$formData['flexFormSheetName'] = $flexFormSheetName;
$formData['flexFormContainerName'] = $flexFormContainerName;
$formData['flexFormContainerIdentifier'] = $flexFormContainerIdentifier;
$formData['flexFormFormPrefix'] = '[data][' . $flexFormSheetName . '][lDEF][' . $flexFormFieldName . '][el]';
// Set initialized data of that section container from compiler to the array part used
// by flexFormElementContainer which prepares parameterArray. Important for initialized
// values of group element.
if (isset($formData['databaseRow'][$fieldName]
['data'][$flexFormSheetName]
['lDEF'][$flexFormFieldName]
['el'][$flexFormContainerIdentifier][$flexFormContainerName]['el']
)
&& is_array(
$formData['databaseRow'][$fieldName]
['data'][$flexFormSheetName]
['lDEF'][$flexFormFieldName]
['el'][$flexFormContainerIdentifier][$flexFormContainerName]['el']
)
) {
$formData['flexFormRowData'] = $formData['databaseRow'][$fieldName]
['data'][$flexFormSheetName]
['lDEF'][$flexFormFieldName]
['el'][$flexFormContainerIdentifier][$flexFormContainerName]['el'];
}
$formData['parameterArray']['itemFormElName'] = 'data[' . $tableName . '][' . $formData['databaseRow']['uid'] . '][' . $fieldName . ']';
// Client-side behavior for event handlers:
$formData['parameterArray']['fieldChangeFunc'] = [];
$formData['parameterArray']['fieldChangeFunc']['TBE_EDITOR_fieldChanged'] = new UpdateValueOnFieldChange(
$tableName,
(string)$formData['databaseRow']['uid'],
$fieldName,
$formData['parameterArray']['itemFormElName']
);
// @todo: check GroupElement for usage of elementBaseName ... maybe kick that thing?
// Feed resulting form data to container structure to render HTML and other result data
$formData['renderType'] = 'flexFormContainerContainer';
$newContainerResult = $this->nodeFactory->create($formData)->render();
$scriptItems = new JavaScriptItems();
$jsonResult = [
'html' => $newContainerResult['html'],
'stylesheetFiles' => [],
'scriptItems' => $scriptItems,
];
foreach ($newContainerResult['stylesheetFiles'] as $stylesheetFile) {
$jsonResult['stylesheetFiles'][] = $this->getRelativePathToStylesheetFile($stylesheetFile);
}
if (!empty($newContainerResult['additionalInlineLanguageLabelFiles'])) {
$labels = [];
foreach ($newContainerResult['additionalInlineLanguageLabelFiles'] as $additionalInlineLanguageLabelFile) {
ArrayUtility::mergeRecursiveWithOverrule(
$labels,
$this->getLabelsFromLocalizationFile($additionalInlineLanguageLabelFile)
);
}
$scriptItems->addGlobalAssignment(['TYPO3' => ['lang' => $labels]]);
}
$this->addJavaScriptModulesToJavaScriptItems($newContainerResult['javaScriptModules'] ?? [], $scriptItems);
return new JsonResponse($jsonResult);
}
}
@@ -0,0 +1,671 @@
<?php
declare(strict_types=1);
/*
* 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 TYPO3\CMS\Backend\Controller;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Backend\Attribute\AsController;
use TYPO3\CMS\Backend\Form\FormDataCompiler;
use TYPO3\CMS\Backend\Form\FormDataGroup\TcaDatabaseRecord;
use TYPO3\CMS\Backend\Form\InlineStackProcessor;
use TYPO3\CMS\Backend\Form\NodeFactory;
use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
use TYPO3\CMS\Core\Crypto\HashService;
use TYPO3\CMS\Core\DataHandling\DataHandler;
use TYPO3\CMS\Core\Http\JsonResponse;
use TYPO3\CMS\Core\Messaging\FlashMessageService;
use TYPO3\CMS\Core\Page\JavaScriptItems;
use TYPO3\CMS\Core\Schema\TcaSchemaFactory;
use TYPO3\CMS\Core\Type\ContextualFeedbackSeverity;
use TYPO3\CMS\Core\Utility\ArrayUtility;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Core\Utility\MathUtility;
/**
* Handle FormEngine inline ajax calls
*/
#[AsController]
readonly class FormInlineAjaxController extends AbstractFormEngineAjaxController
{
public function __construct(
private FormDataCompiler $formDataCompiler,
private HashService $hashService,
private NodeFactory $nodeFactory,
private InlineStackProcessor $inlineStackProcessor,
private TcaSchemaFactory $tcaSchemaFactory,
private FlashMessageService $flashMessageService,
) {}
/**
* Create a new inline child via AJAX.
*/
public function createAction(ServerRequestInterface $request): ResponseInterface
{
$ajaxArguments = $request->getParsedBody()['ajax'] ?? $request->getQueryParams()['ajax'];
$parentConfig = $this->extractSignedParentConfigFromRequest((string)$ajaxArguments['context']);
$domObjectId = $ajaxArguments[0] ?? '';
$inlineFirstPid = $this->getInlineFirstPidFromDomObjectId($domObjectId);
if (!MathUtility::canBeInterpretedAsInteger($inlineFirstPid)
&& !str_starts_with((string)$inlineFirstPid, 'NEW')
) {
throw new \RuntimeException(
'inlineFirstPid should either be an integer or a "NEW..." string',
1521220491
);
}
$childChildUid = null;
if (isset($ajaxArguments[1]) && MathUtility::canBeInterpretedAsInteger($ajaxArguments[1])) {
$childChildUid = (int)$ajaxArguments[1];
}
// Parse the DOM identifier, add the levels to the structure stack
$inlineStructure = $this->inlineStackProcessor->getStructureFromString($domObjectId, $this->tcaSchemaFactory->all());
$inlineStructure = $this->inlineStackProcessor->addAjaxConfigurationToStructure($inlineStructure, $parentConfig);
$inlineTopMostParent = $this->inlineStackProcessor->getStructureLevelFromStructure($inlineStructure, 0);
// Parent, this table embeds the child table
$inlineParent = $this->inlineStackProcessor->getStructureLevelFromStructure($inlineStructure, -1);
// Child, a record from this table should be rendered
$child = $this->inlineStackProcessor->getUnstableStructureFromStructure($inlineStructure);
if (isset($child['uid']) && MathUtility::canBeInterpretedAsInteger($child['uid'])) {
// If uid comes in, it is the id of the record neighbor record "create after"
$childVanillaUid = -1 * abs((int)$child['uid']);
} else {
// Else inline first Pid is the storage pid of new inline records
$childVanillaUid = $inlineFirstPid;
}
$childTableName = $parentConfig['foreign_table'];
$formDataCompilerInput = [
'request' => $request,
'command' => 'new',
'tableName' => $childTableName,
'vanillaUid' => $childVanillaUid,
'isInlineChild' => true,
'inlineStructure' => $inlineStructure,
'inlineFirstPid' => $inlineFirstPid,
'inlineParentUid' => $inlineParent['uid'],
'inlineParentTableName' => $inlineParent['table'],
'inlineParentFieldName' => $inlineParent['field'],
'inlineParentConfig' => $parentConfig,
'inlineTopMostParentUid' => $inlineTopMostParent['uid'],
'inlineTopMostParentTableName' => $inlineTopMostParent['table'],
'inlineTopMostParentFieldName' => $inlineTopMostParent['field'],
];
if ($childChildUid) {
$formDataCompilerInput['inlineChildChildUid'] = $childChildUid;
}
$childData = $this->formDataCompiler->compile($formDataCompilerInput, GeneralUtility::makeInstance(TcaDatabaseRecord::class));
if (($parentConfig['foreign_selector'] ?? false) && ($parentConfig['appearance']['useCombination'] ?? false)) {
// We have a foreign_selector. So, we just created a new record on an intermediate table in $childData.
// Now, if a valid id is given as second ajax parameter, the intermediate row should be connected to an
// existing record of the child-child table specified by the given uid. If there is no such id, user
// clicked on "created new" and a new child-child should be created, too.
if ($childChildUid) {
// Fetch existing child child
$childData['databaseRow'][$parentConfig['foreign_selector']] = [
$childChildUid,
];
$childData['combinationChild'] = $this->compileChildChild($request, $childData, $parentConfig, $inlineStructure);
} else {
$formDataCompilerInput = [
'request' => $request,
'command' => 'new',
'tableName' => $this->getChildChildTableName($parentConfig['foreign_selector'], $childData),
'vanillaUid' => $inlineFirstPid,
'isInlineChild' => true,
'isInlineAjaxOpeningContext' => true,
'inlineStructure' => $inlineStructure,
'inlineFirstPid' => $inlineFirstPid,
];
$childData['combinationChild'] = $this->formDataCompiler->compile($formDataCompilerInput, GeneralUtility::makeInstance(TcaDatabaseRecord::class));
}
}
$childData['inlineParentUid'] = $inlineParent['uid'];
$childData['renderType'] = 'inlineRecordContainer';
$childResult = $this->nodeFactory->create($childData)->render();
$jsonArray = [
'data' => '',
'stylesheetFiles' => [],
'scriptItems' => new JavaScriptItems(),
'compilerInput' => [
'uid' => $childData['databaseRow']['uid'],
'childChildUid' => $childChildUid,
],
];
$jsonArray = $this->mergeChildResultIntoJsonResult($jsonArray, $childResult);
return new JsonResponse($jsonArray);
}
/**
* Show the details of a child record.
*/
public function detailsAction(ServerRequestInterface $request): ResponseInterface
{
$ajaxArguments = $request->getParsedBody()['ajax'] ?? $request->getQueryParams()['ajax'];
$domObjectId = $ajaxArguments[0] ?? '';
$inlineFirstPid = $this->getInlineFirstPidFromDomObjectId($domObjectId);
$parentConfig = $this->extractSignedParentConfigFromRequest((string)$ajaxArguments['context']);
// Parse the DOM identifier, add the levels to the structure stack
$inlineStructure = $this->inlineStackProcessor->getStructureFromString($domObjectId, $this->tcaSchemaFactory->all());
$inlineStructure = $this->inlineStackProcessor->addAjaxConfigurationToStructure($inlineStructure, $parentConfig);
// Parent, this table embeds the child table
$inlineParent = $this->inlineStackProcessor->getStructureLevelFromStructure($inlineStructure, -1);
$parentFieldName = $inlineParent['field'];
// Set flag in config so that only the fields are rendered
// @todo: Solve differently / rename / whatever
$parentConfig['renderFieldsOnly'] = true;
$parentData = [
'processedTca' => [
'columns' => [
$parentFieldName => [
'config' => $parentConfig,
],
],
],
'uid' => $inlineParent['uid'],
'tableName' => $inlineParent['table'],
'inlineFirstPid' => $inlineFirstPid,
// Hand over given original return url to compile stack. Needed if inline children compile links to
// another view (eg. edit metadata in a nested inline situation like news with inline content element image),
// so the back link is still the link from the original request. See issue #82525. This is additionally
// given down in TcaInline data provider to compiled children data.
'returnUrl' => $parentConfig['originalReturnUrl'],
];
// Child, a record from this table should be rendered
$child = $this->inlineStackProcessor->getUnstableStructureFromStructure($inlineStructure);
$childData = $this->compileChild($request, $parentData, $parentFieldName, (int)$child['uid'], $inlineStructure);
$childData['inlineParentUid'] = (int)$inlineParent['uid'];
$childData['renderType'] = 'inlineRecordContainer';
$childResult = $this->nodeFactory->create($childData)->render();
$jsonArray = [
'data' => '',
'stylesheetFiles' => [],
'scriptItems' => new JavaScriptItems(),
];
$jsonArray = $this->mergeChildResultIntoJsonResult($jsonArray, $childResult);
return new JsonResponse($jsonArray);
}
/**
* Adds localizations or synchronizes the locations of all child records.
* Handle AJAX calls to localize all records of a parent, localize a single record or to synchronize with the original language parent.
*
* @param ServerRequestInterface $request the incoming request
* @return ResponseInterface the filled response
*/
public function synchronizeLocalizeAction(ServerRequestInterface $request): ResponseInterface
{
$ajaxArguments = $request->getParsedBody()['ajax'] ?? $request->getQueryParams()['ajax'];
$domObjectId = $ajaxArguments[0] ?? '';
$type = $ajaxArguments[1] ?? null;
$parentConfig = $this->extractSignedParentConfigFromRequest((string)$ajaxArguments['context']);
// Parse the DOM identifier (string), add the levels to the structure stack (array), load the TCA config:
$inlineStructure = $this->inlineStackProcessor->getStructureFromString($domObjectId, $this->tcaSchemaFactory->all());
$inlineStructure = $this->inlineStackProcessor->addAjaxConfigurationToStructure($inlineStructure, $parentConfig);
$inlineFirstPid = $this->getInlineFirstPidFromDomObjectId($domObjectId);
$jsonArray = [
'data' => '',
'stylesheetFiles' => [],
'scriptItems' => new JavaScriptItems(),
'compilerInput' => [
'localize' => [],
],
];
if ($type === 'localize' || $type === 'synchronize' || MathUtility::canBeInterpretedAsInteger($type)) {
// Parent, this table embeds the child table
$parent = $this->inlineStackProcessor->getStructureLevelFromStructure($inlineStructure, -1);
$parentFieldName = $parent['field'];
$processedTca = $GLOBALS['TCA'][$parent['table']];
$processedTca['columns'][$parentFieldName]['config'] = $parentConfig;
$formDataCompilerInputForParent = [
'request' => $request,
'vanillaUid' => (int)$parent['uid'],
'command' => 'edit',
'tableName' => $parent['table'],
'processedTca' => $processedTca,
'inlineFirstPid' => $inlineFirstPid,
'columnsToProcess' => [
$parentFieldName,
],
// @todo: still needed? NO!
'inlineStructure' => $inlineStructure,
// Do not compile existing children, we don't need them now
'inlineCompileExistingChildren' => false,
];
// Full TcaDatabaseRecord is required here to have the list of connected uids $oldItemList
$parentData = $this->formDataCompiler->compile($formDataCompilerInputForParent, GeneralUtility::makeInstance(TcaDatabaseRecord::class));
$parentConfig = $parentData['processedTca']['columns'][$parentFieldName]['config'];
$parentLanguageField = $parentData['processedTca']['ctrl']['languageField'];
$parentLanguage = $parentData['databaseRow'][$parentLanguageField];
$oldItemList = $parentData['databaseRow'][$parentFieldName];
// DataHandler cannot handle arrays as field value
if (is_array($parentLanguage)) {
$parentLanguage = implode(',', $parentLanguage);
}
$cmd = [];
// Localize a single child element from default language of the parent element
if (MathUtility::canBeInterpretedAsInteger($type)) {
$cmd[$parent['table']][$parent['uid']]['inlineLocalizeSynchronize'] = [
'field' => $parent['field'],
'language' => $parentLanguage,
'ids' => [$type],
];
} else {
// Either localize or synchronize all child elements from default language of the parent element
$cmd[$parent['table']][$parent['uid']]['inlineLocalizeSynchronize'] = [
'field' => $parent['field'],
'language' => $parentLanguage,
'action' => $type,
];
}
$tce = GeneralUtility::makeInstance(DataHandler::class);
$tce->start([], $cmd);
$tce->process_cmdmap();
$newItemList = $tce->registerDBList[$parent['table']][$parent['uid']][$parentFieldName];
$oldItems = $this->getInlineRelatedRecordsUidArray($oldItemList);
$newItems = $this->getInlineRelatedRecordsUidArray($newItemList);
// Render error messages from DataHandler
$tce->printLogErrorMessages();
$messages = $this->flashMessageService->getMessageQueueByIdentifier()->getAllMessagesAndFlush();
if (!empty($messages)) {
foreach ($messages as $message) {
$jsonArray['messages'][] = [
'title' => $message->getTitle(),
'message' => $message->getMessage(),
'severity' => $message->getSeverity(),
];
if ($message->getSeverity() === ContextualFeedbackSeverity::ERROR) {
$jsonArray['hasErrors'] = true;
}
}
}
// Set the items that should be removed in the forms view:
$removedItems = array_diff($oldItems, $newItems);
$jsonArray['compilerInput']['delete'] = $removedItems;
$localizedItems = array_diff($newItems, $oldItems);
foreach ($localizedItems as $i => $childUid) {
$childData = $this->compileChild($request, $parentData, $parentFieldName, (int)$childUid, $inlineStructure);
$childData['inlineParentUid'] = (int)$parent['uid'];
$childData['renderType'] = 'inlineRecordContainer';
$childResult = $this->nodeFactory->create($childData)->render();
$jsonArray = $this->mergeChildResultIntoJsonResult($jsonArray, $childResult);
// Get the name of the field used as foreign selector (if any):
$foreignSelector = isset($parentConfig['foreign_selector']) && $parentConfig['foreign_selector'] ? $parentConfig['foreign_selector'] : false;
$selectedValue = $foreignSelector ? $childData['databaseRow'][$foreignSelector] : null;
if (is_array($selectedValue)) {
$selectedValue = $selectedValue[0];
}
$jsonArray['compilerInput']['localize'][$i] = [
'uid' => $childUid,
'selectedValue' => $selectedValue,
];
// Remove possible virtual records in the form which showed that a child records could be localized:
$transOrigPointerFieldName = $childData['processedTca']['ctrl']['transOrigPointerField'];
if (isset($childData['databaseRow'][$transOrigPointerFieldName]) && $childData['databaseRow'][$transOrigPointerFieldName]) {
$transOrigPointerFieldValue = $childData['databaseRow'][$transOrigPointerFieldName];
if (is_array($transOrigPointerFieldValue)) {
$transOrigPointerFieldValue = $transOrigPointerFieldValue[0];
if (is_array($transOrigPointerFieldValue) && ($transOrigPointerFieldValue['uid'] ?? false)) {
// With nested inline containers (eg. fal sys_file_reference), row[l10n_parent][0] is sometimes
// a table / row combination again. See tx_styleguide_file file_5. If this happens we
// pick the uid field from the array ... Basically, we need the uid of the 'default language' record,
// since this is used in JS to locate and remove the 'shadowed' container.
// @todo: Find out if this is really necessary that sometimes ['databaseRow']['l10n_parent'][0]
// is resolved to a direct uid, and sometimes it's an array with items. Could this be harmonized?
$transOrigPointerFieldValue = $transOrigPointerFieldValue['uid'];
}
}
$jsonArray['compilerInput']['localize'][$i]['remove'] = $transOrigPointerFieldValue;
}
}
}
return new JsonResponse($jsonArray);
}
/**
* Store status of inline children expand / collapse state in backend user uC.
*
* @param ServerRequestInterface $request the incoming request
* @return ResponseInterface the filled response
*/
public function expandOrCollapseAction(ServerRequestInterface $request): ResponseInterface
{
$ajaxArguments = $request->getParsedBody()['ajax'] ?? $request->getQueryParams()['ajax'];
[$domObjectId, $expand, $collapse] = $ajaxArguments;
// Parse the DOM identifier (string), add the levels to the structure stack (array), don't load TCA config
$inlineStructure = $this->inlineStackProcessor->getStructureFromString($domObjectId, $this->tcaSchemaFactory->all());
$backendUser = $this->getBackendUserAuthentication();
// The current table - for this table we should add/import records
$currentTable = $this->inlineStackProcessor->getUnstableStructureFromStructure($inlineStructure);
$currentTable = $currentTable['table'];
// The top parent table - this table embeds the current table
$top = $this->inlineStackProcessor->getStructureLevelFromStructure($inlineStructure, 0);
$topTable = $top['table'];
$topUid = $top['uid'];
$inlineView = $this->getInlineExpandCollapseStateArray();
// Only do some action if the top record and the current record were saved before
if (MathUtility::canBeInterpretedAsInteger($topUid)) {
$expandUids = GeneralUtility::trimExplode(',', $expand);
$collapseUids = GeneralUtility::trimExplode(',', $collapse);
// Set records to be expanded
foreach ($expandUids as $uid) {
$inlineView[$topTable][$topUid][$currentTable][] = $uid;
}
// Set records to be collapsed
foreach ($collapseUids as $uid) {
$inlineView[$topTable][$topUid][$currentTable] = $this->removeFromArray($uid, $inlineView[$topTable][$topUid][$currentTable]);
}
// Save states back to database
if (is_array($inlineView[$topTable][$topUid][$currentTable])) {
$inlineView[$topTable][$topUid][$currentTable] = array_unique($inlineView[$topTable][$topUid][$currentTable]);
$backendUser->uc['inlineView'] = json_encode($inlineView);
$backendUser->writeUC();
}
}
return new JsonResponse([]);
}
/**
* Compile a full child record
*
* @param array $parentData Result array of parent
* @param string $parentFieldName Name of parent field
* @param int $childUid Uid of child to compile
* @param array $inlineStructure Current inline structure
* @return array Full result array
*
* @todo: This clones methods compileChild from TcaInline Provider. Find a better abstraction
* @todo: to also encapsulate the more complex scenarios with combination child and friends.
*/
protected function compileChild(ServerRequestInterface $request, array $parentData, $parentFieldName, $childUid, array $inlineStructure)
{
$parentConfig = $parentData['processedTca']['columns'][$parentFieldName]['config'];
$inlineTopMostParent = $this->inlineStackProcessor->getStructureLevelFromStructure($inlineStructure, 0);
$childTableName = $inlineStructure['unstable']['table'] ?? null;
if (!$childTableName) {
throw new \RuntimeException('No unstable inline structure found', 1733754245);
}
$formDataCompilerInput = [
'request' => $request,
'command' => 'edit',
'tableName' => $childTableName,
'vanillaUid' => (int)$childUid,
'returnUrl' => $parentData['returnUrl'],
'isInlineChild' => true,
'inlineStructure' => $inlineStructure,
'inlineFirstPid' => $parentData['inlineFirstPid'],
'inlineParentConfig' => $parentConfig,
'isInlineAjaxOpeningContext' => true,
// values of the current parent element
// it is always a string either an id or new...
'inlineParentUid' => $parentData['databaseRow']['uid'] ?? $parentData['uid'],
'inlineParentTableName' => $parentData['tableName'],
'inlineParentFieldName' => $parentFieldName,
// values of the top most parent element set on first level and not overridden on following levels
'inlineTopMostParentUid' => $inlineTopMostParent['uid'],
'inlineTopMostParentTableName' => $inlineTopMostParent['table'],
'inlineTopMostParentFieldName' => $inlineTopMostParent['field'],
];
// For foreign_selector with useCombination $mainChild is the mm record
// and $combinationChild is the child-child. For "normal" relations, $mainChild
// is just the normal child record and $combinationChild is empty.
$mainChild = $this->formDataCompiler->compile($formDataCompilerInput, GeneralUtility::makeInstance(TcaDatabaseRecord::class));
if (($parentConfig['foreign_selector'] ?? false) && ($parentConfig['appearance']['useCombination'] ?? false)) {
// This kicks in if opening an existing mainChild that has a child-child set
$mainChild['combinationChild'] = $this->compileChildChild($request, $mainChild, $parentConfig, $inlineStructure);
}
return $mainChild;
}
/**
* With useCombination set, not only content of the intermediate table, but also
* the connected child should be rendered in one go. Prepare this here.
*
* @param array $child Full data array of "mm" record
* @param array $parentConfig TCA configuration of "parent"
* @param array $inlineStructure Current inline structure
* @return array Full data array of child
*/
protected function compileChildChild(ServerRequestInterface $request, array $child, array $parentConfig, array $inlineStructure)
{
// foreign_selector on intermediate is probably type=select, so data provider of this table resolved that to the uid already
$childChildUid = $child['databaseRow'][$parentConfig['foreign_selector']][0];
$formDataCompilerInput = [
'request' => $request,
'command' => 'edit',
'tableName' => $this->getChildChildTableName($parentConfig['foreign_selector'] ?? '', $child),
'vanillaUid' => (int)$childChildUid,
'isInlineChild' => true,
'isInlineAjaxOpeningContext' => true,
// @todo: this is the wrong inline structure, isn't it? Shouldn't contain it the part from child child, too?
'inlineStructure' => $inlineStructure,
'inlineFirstPid' => $child['inlineFirstPid'],
// values of the top most parent element set on first level and not overridden on following levels
'inlineTopMostParentUid' => $child['inlineTopMostParentUid'],
'inlineTopMostParentTableName' => $child['inlineTopMostParentTableName'],
'inlineTopMostParentFieldName' => $child['inlineTopMostParentFieldName'],
];
return $this->formDataCompiler->compile($formDataCompilerInput, GeneralUtility::makeInstance(TcaDatabaseRecord::class));
}
/**
* Merge stuff from child array into json array.
* This method is needed since ajax handling methods currently need to put scriptCalls before and after child code.
*
* @param array $jsonResult Given json result
* @param array $childResult Given child result
* @return array Merged json array
*/
protected function mergeChildResultIntoJsonResult(array $jsonResult, array $childResult)
{
/** @var JavaScriptItems $scriptItems */
$scriptItems = $jsonResult['scriptItems'];
$jsonResult['data'] .= $childResult['html'];
$jsonResult['stylesheetFiles'] = [];
foreach ($childResult['stylesheetFiles'] as $stylesheetFile) {
$jsonResult['stylesheetFiles'][] = $this->getRelativePathToStylesheetFile($stylesheetFile);
}
if (!empty($childResult['inlineData'])) {
$jsonResult['inlineData'] = $childResult['inlineData'];
}
if (!empty($childResult['additionalInlineLanguageLabelFiles'])) {
$labels = [];
foreach ($childResult['additionalInlineLanguageLabelFiles'] as $additionalInlineLanguageLabelFile) {
ArrayUtility::mergeRecursiveWithOverrule(
$labels,
$this->getLabelsFromLocalizationFile($additionalInlineLanguageLabelFile)
);
}
$scriptItems->addGlobalAssignment(['TYPO3' => ['lang' => $labels]]);
}
$this->addJavaScriptModulesToJavaScriptItems($childResult['javaScriptModules'] ?? [], $scriptItems);
return $jsonResult;
}
/**
* Gets an array with the uids of related records out of a list of items.
* This list could contain more information than required. This methods just
* extracts the uids.
*
* @param string $itemList The list of related child records
* @return array An array with uids
*/
protected function getInlineRelatedRecordsUidArray($itemList)
{
$itemArray = GeneralUtility::trimExplode(',', $itemList, true);
// Perform modification of the selected items array:
foreach ($itemArray as &$value) {
$parts = explode('|', $value, 2);
$value = $parts[0];
}
unset($value);
return $itemArray;
}
/**
* Get expand / collapse state of inline items
*
* @return array
*/
protected function getInlineExpandCollapseStateArray()
{
$backendUser = $this->getBackendUserAuthentication();
if (!$this->backendUserHasUcInlineView($backendUser)) {
return [];
}
$inlineView = json_decode($backendUser->uc['inlineView'], true);
if (!is_array($inlineView)) {
$inlineView = [];
}
return $inlineView;
}
/**
* Method to check whether the backend user has the property inline view for the current IRRE item.
* In existing or old IRRE items the attribute may not exist, then the json_decode will fail.
*
* @return bool
*/
protected function backendUserHasUcInlineView(BackendUserAuthentication $backendUser)
{
return !empty($backendUser->uc['inlineView']);
}
/**
* Remove an element from an array.
*
* @param mixed $needle The element to be removed.
* @param array $haystack The array the element should be removed from.
* @param bool $strict Search elements strictly.
* @return array The array $haystack without the $needle
*/
protected function removeFromArray($needle, $haystack, $strict = false)
{
$pos = array_search($needle, $haystack, $strict);
if ($pos !== false) {
unset($haystack[$pos]);
}
return $haystack;
}
/**
* Get inlineFirstPid from a given objectId string
*
* @param string $domObjectId The id attribute of an element
* @return int|string|null Pid or null
*/
protected function getInlineFirstPidFromDomObjectId(string $domObjectId)
{
// Substitute FlexForm addition and make parsing a bit easier
$domObjectId = str_replace('---', ':', $domObjectId);
// The starting pattern of an object identifier (e.g. "data-<firstPidValue>-<anything>)
$pattern = '/^data-(.+?)-(.+)$/';
if (preg_match($pattern, $domObjectId, $match)) {
return $match[1];
}
return null;
}
/**
* Validates the config that is transferred over the wire to provide the
* correct TCA config for the parent table
*
* @throws \RuntimeException
*/
protected function extractSignedParentConfigFromRequest(string $contextString): array
{
if ($contextString === '') {
throw new \RuntimeException('Empty context string given', 1489751361);
}
$context = json_decode($contextString, true);
if (empty($context['config'])) {
throw new \RuntimeException('Empty context config section given', 1489751362);
}
if (!hash_equals($this->hashService->hmac((string)$context['config'], 'InlineContext'), (string)$context['hmac'])) {
throw new \RuntimeException('Hash does not validate', 1489751363);
}
return json_decode($context['config'], true);
}
/**
* The child-child table name is set in the child TCA "the selector field" and is depending on
* the TCA type (select or group) either the "foreign_table" or the (first) "allowed" table.
*/
protected function getChildChildTableName(string $foreignSelector, array $childConfiguration): string
{
$config = $childConfiguration['processedTca']['columns'][$foreignSelector]['config'] ?? [];
$type = $config['type'] ?? '';
return match ($type) {
'select' => $config['foreign_table'] ?? '',
'group' => GeneralUtility::trimExplode(',', $config['allowed'] ?? '', true)[0] ?? '',
default => '',
};
}
protected function getBackendUserAuthentication(): BackendUserAuthentication
{
return $GLOBALS['BE_USER'];
}
}
@@ -0,0 +1,228 @@
<?php
declare(strict_types=1);
/*
* 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 TYPO3\CMS\Backend\Controller;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Backend\Attribute\AsController;
use TYPO3\CMS\Backend\Dto\Tree\SelectTreeItem;
use TYPO3\CMS\Backend\Dto\Tree\TreeItem;
use TYPO3\CMS\Backend\Form\FormDataCompiler;
use TYPO3\CMS\Backend\Form\FormDataGroup\TcaSelectTreeAjaxFieldData;
use TYPO3\CMS\Core\Configuration\FlexForm\FlexFormTools;
use TYPO3\CMS\Core\Http\JsonResponse;
use TYPO3\CMS\Core\Schema\TcaSchemaFactory;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Backend controller for selectTree ajax operations
*/
#[AsController]
readonly class FormSelectTreeAjaxController
{
public function __construct(
private FormDataCompiler $formDataCompiler,
private FlexFormTools $flexFormTools,
private TcaSchemaFactory $schemaFactory,
) {}
/**
* Returns json representing category tree
*
* @throws \RuntimeException
*/
public function fetchDataAction(ServerRequestInterface $request): ResponseInterface
{
$tableName = $request->getQueryParams()['tableName'] ?? '';
$fieldName = $request->getQueryParams()['fieldName'] ?? '';
// Prepare processedTca: Remove all column definitions except the one that contains
// our tree definition. This way only this field is calculated, everything else is ignored.
if (!$this->schemaFactory->has($tableName)) {
throw new \RuntimeException(
'TCA for table ' . $tableName . ' not found',
1479386729
);
}
$schema = $this->schemaFactory->get($tableName);
if (!$schema->hasField($fieldName)) {
throw new \RuntimeException(
'TCA for table ' . $tableName . ' and field ' . $fieldName . ' not found',
1479386990
);
}
// @todo Replace with a mutable schema
$processedTca = $GLOBALS['TCA'][$tableName];
// Force given record type and set showitem to our field only
$recordTypeValue = $request->getQueryParams()['recordTypeValue'];
$processedTca['types'][$recordTypeValue]['showitem'] = $fieldName;
// Unset all columns except our field
$processedTca['columns'] = [
$fieldName => $processedTca['columns'][$fieldName],
];
$dataStructureIdentifier = '';
$flexFormSheetName = '';
$flexFormFieldName = '';
$flexFormContainerIdentifier = '';
$flexFormContainerFieldName = '';
$flexSectionContainerPreparation = [];
if ($processedTca['columns'][$fieldName]['config']['type'] === 'flex') {
if (!empty($request->getQueryParams()['dataStructureIdentifier'])) {
$dataStructureIdentifier = $request->getQueryParams()['dataStructureIdentifier'];
}
$flexFormSheetName = $request->getQueryParams()['flexFormSheetName'];
$flexFormFieldName = $request->getQueryParams()['flexFormFieldName'];
$flexFormContainerName = $request->getQueryParams()['flexFormContainerName'];
$flexFormContainerIdentifier = $request->getQueryParams()['flexFormContainerIdentifier'];
$flexFormContainerFieldName = $request->getQueryParams()['flexFormContainerFieldName'];
$flexFormSectionContainerIsNew = (bool)$request->getQueryParams()['flexFormSectionContainerIsNew'];
$dataStructure = $this->flexFormTools->parseDataStructureByIdentifier($dataStructureIdentifier, $schema);
// Reduce given data structure down to the relevant element only
if (empty($flexFormContainerFieldName)) {
if (isset($dataStructure['sheets'][$flexFormSheetName]['ROOT']
['el'][$flexFormFieldName])
) {
$dataStructure = [
'sheets' => [
$flexFormSheetName => [
'ROOT' => [
'type' => 'array',
'el' => [
$flexFormFieldName => $dataStructure['sheets'][$flexFormSheetName]['ROOT']
['el'][$flexFormFieldName],
],
],
],
],
];
}
} elseif (isset($dataStructure['sheets'][$flexFormSheetName]['ROOT']
['el'][$flexFormFieldName]
['el'][$flexFormContainerName]
['el'][$flexFormContainerFieldName])
) {
// If this is a tree in a section container that has just been added by the FlexFormAjaxController
// "new container" action, then this container is not yet persisted, so we need to trigger the
// TcaFlexProcess data provider again to prepare the DS and databaseRow of that container.
if ($flexFormSectionContainerIsNew) {
$flexSectionContainerPreparation = [
'flexFormSheetName' => $flexFormSheetName,
'flexFormFieldName' => $flexFormFieldName,
'flexFormContainerName' => $flexFormContainerName,
'flexFormContainerIdentifier' => $flexFormContainerIdentifier,
];
}
// Now restrict the data structure to our tree element only
$dataStructure = [
'sheets' => [
$flexFormSheetName => [
'ROOT' => [
'type' => 'array',
'el' => [
$flexFormFieldName => [
'section' => 1,
'type' => 'array',
'el' => [
$flexFormContainerName => [
'type' => 'array',
'el' => [
$flexFormContainerFieldName => $dataStructure['sheets'][$flexFormSheetName]['ROOT']
['el'][$flexFormFieldName]
['el'][$flexFormContainerName]
['el'][$flexFormContainerFieldName],
],
],
],
],
],
],
],
],
];
}
$processedTca['columns'][$fieldName]['config']['ds'] = $dataStructure;
$processedTca['columns'][$fieldName]['config']['dataStructureIdentifier'] = $dataStructureIdentifier;
}
$formDataCompilerInput = [
'request' => $request,
'tableName' => $tableName,
'vanillaUid' => (int)$request->getQueryParams()['uid'],
'command' => $request->getQueryParams()['command'],
'processedTca' => $processedTca,
'recordTypeValue' => $recordTypeValue,
'selectTreeCompileItems' => true,
'flexSectionContainerPreparation' => $flexSectionContainerPreparation,
];
if (!empty($request->getQueryParams()['overrideValues'])) {
$formDataCompilerInput['overrideValues'] = json_decode($request->getQueryParams()['overrideValues'], true);
}
if (!empty($request->getQueryParams()['defaultValues'])) {
$formDataCompilerInput['defaultValues'] = json_decode($request->getQueryParams()['defaultValues'], true);
}
$formData = $this->formDataCompiler->compile($formDataCompilerInput, GeneralUtility::makeInstance(TcaSelectTreeAjaxFieldData::class));
if ($formData['processedTca']['columns'][$fieldName]['config']['type'] === 'flex') {
if (empty($flexFormContainerFieldName)) {
$treeData = $formData['processedTca']['columns'][$fieldName]['config']['ds']
['sheets'][$flexFormSheetName]['ROOT']
['el'][$flexFormFieldName]['config']['items'];
} else {
$treeData = $formData['processedTca']['columns'][$fieldName]['config']['ds']
['sheets'][$flexFormSheetName]['ROOT']
['el'][$flexFormFieldName]
['children'][$flexFormContainerIdentifier]
['el'][$flexFormContainerFieldName]['config']['items'];
}
} else {
$treeData = $formData['processedTca']['columns'][$fieldName]['config']['items'];
}
$data = [];
foreach ($treeData ?? [] as $item) {
$treeItem = new SelectTreeItem(
item: new TreeItem(
identifier: (string)$item['identifier'],
parentIdentifier: (string)($item['parentIdentifier'] ?? ''),
recordType: (string)($item['recordType'] ?? ''),
name: (string)($item['name'] ?? ''),
prefix: (string)($item['prefix'] ?? ''),
suffix: (string)($item['suffix'] ?? ''),
tooltip: (string)($item['tooltip'] ?? ''),
depth: (int)($item['depth'] ?? 0),
hasChildren: (bool)($item['hasChildren'] ?? false),
loaded: true,
icon: (string)($item['icon'] ?? ''),
overlayIcon: (string)($item['overlayIcon'] ?? ''),
statusInformation: (array)($item['statusInformation'] ?? []),
labels: (array)($item['labels'] ?? []),
),
checked: (bool)($item['checked'] ?? false),
selectable: (bool)($item['selectable'] ?? false),
);
$data[] = $treeItem;
}
return new JsonResponse($data);
}
}
@@ -0,0 +1,185 @@
<?php
declare(strict_types=1);
/*
* 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 TYPO3\CMS\Backend\Controller;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Backend\Attribute\AsController;
use TYPO3\CMS\Backend\Utility\BackendUtility;
use TYPO3\CMS\Core\Context\Context;
use TYPO3\CMS\Core\Crypto\HashService;
use TYPO3\CMS\Core\DataHandling\Model\RecordStateFactory;
use TYPO3\CMS\Core\DataHandling\SlugHelper;
use TYPO3\CMS\Core\Http\JsonResponse;
use TYPO3\CMS\Core\Utility\ArrayUtility;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Handle FormEngine AJAX calls for Slug validation and sanitization
*
* @internal This class is a specific Backend controller implementation and is not considered part of the Public TYPO3 API.
*/
#[AsController]
readonly class FormSlugAjaxController extends AbstractFormEngineAjaxController
{
public function __construct(
private Context $context,
private HashService $hashService
) {}
/**
* Validates a given slug against the site and give a suggestion when it's already in use
*
* For new records this will look like this:
* - If "slug" field is empty, take the other fields, and generate the slug based on the sent fields.
* - JS: adapt the "placeholder" value only, as on save the field will be filled with the value via DataHandler
* - If "slug" field is not empty (= "unlocked" and manually typed in)
* - sanitize the slug
* - If 'uniqueInSite' is set check if it's unique for the site
* - If not unique propose another slug and return this with the flag hasConflicts = true
* - If 'uniqueInPid' is set check if it's unique for the pid
* - If not unique propose another slug and return this with the flag hasConflicts = true
*
* For existing records:
* - sanitize the slug
* - If 'uniqueInSite' is set check if it's unique for the site
* - If not unique propose another slug and return this with the flag hasConflicts = true
* - If 'uniqueInPid' is set check if it's unique for the pid
* - If not unique propose another slug and return this with the flag hasConflicts = true
* - If the slug has changed from the existing database record (@todo)
* - Show a message that the old URL will stop working (possibly add a redirect via checkbox)
* - If the page has subpages, show a warning that the subpages WILL NOT BE MODIFIED and keep the OLD url
*
* @param ServerRequestInterface $request
* @throws \RuntimeException
*/
public function suggestAction(ServerRequestInterface $request): ResponseInterface
{
$this->checkRequest($request);
$queryParameters = $request->getParsedBody() ?? [];
$values = $queryParameters['values'];
$mode = $queryParameters['mode'];
$tableName = (string)($queryParameters['tableName'] ?? '');
$pid = (int)$queryParameters['pageId'];
$parentPageId = (int)$queryParameters['parentPageId'];
$recordId = (int)$queryParameters['recordId'];
$languageId = (int)$queryParameters['language'];
$fieldName = $queryParameters['fieldName'];
$fieldConfig = $GLOBALS['TCA'][$tableName]['columns'][$fieldName]['config'] ?? [];
$row = (array)BackendUtility::getRecord($tableName, $recordId);
$recordType = BackendUtility::getTCAtypeValue($tableName, $row, true);
if ($recordType !== null) {
$columnsOverridesConfigOfField = $GLOBALS['TCA'][$tableName]['types'][$recordType]['columnsOverrides'][$fieldName]['config'] ?? null;
if ($columnsOverridesConfigOfField) {
ArrayUtility::mergeRecursiveWithOverrule($fieldConfig, $columnsOverridesConfigOfField);
}
}
if (empty($fieldConfig)) {
throw new \RuntimeException(
'No valid field configuration for table ' . $tableName . ' field name ' . $fieldName . ' found.',
1535379534
);
}
$evalInfo = !empty($fieldConfig['eval']) ? GeneralUtility::trimExplode(',', $fieldConfig['eval'], true) : [];
$hasToBeUniqueInDb = in_array('unique', $evalInfo, true);
$hasToBeUniqueInSite = in_array('uniqueInSite', $evalInfo, true);
$hasToBeUniqueInPid = in_array('uniqueInPid', $evalInfo, true);
$hasConflict = false;
$recordData = $values;
if (!isset($recordData['uid'])) {
$recordData['uid'] = $recordId;
}
$recordData['pid'] = $pid;
if (!empty($GLOBALS['TCA'][$tableName]['ctrl']['languageField'])) {
$recordData[$GLOBALS['TCA'][$tableName]['ctrl']['languageField']] = $languageId;
}
if ($tableName === 'pages' && empty($recordData['is_siteroot'])) {
$recordData['is_siteroot'] = $row['is_siteroot'] ?? false;
}
$workspaceId = $this->context->getPropertyFromAspect('workspace', 'id');
$slug = GeneralUtility::makeInstance(SlugHelper::class, $tableName, $fieldName, $fieldConfig, $workspaceId);
if ($mode === 'auto') {
// New page - Feed incoming values to generator
$proposal = $slug->generate($recordData, $pid);
} elseif ($mode === 'recreate') {
$proposal = $slug->generate($recordData, $parentPageId);
} elseif ($mode === 'manual') {
// Existing record - Fetch full record and only validate against the new "slug" field.
$proposal = $slug->sanitize($values['manual']);
} else {
throw new \RuntimeException('mode must be either "auto", "recreate" or "manual"', 1535835666);
}
$state = RecordStateFactory::forName($tableName)
->fromArray($recordData, $pid, $recordId);
if ($hasToBeUniqueInDb && !$slug->isUniqueInTable($proposal, $state)) {
$hasConflict = true;
$proposal = $slug->buildSlugForUniqueInTable($proposal, $state);
}
if ($hasToBeUniqueInSite && !$slug->isUniqueInSite($proposal, $state)) {
$hasConflict = true;
$proposal = $slug->buildSlugForUniqueInSite($proposal, $state);
}
if ($hasToBeUniqueInPid && !$slug->isUniqueInPid($proposal, $state)) {
$hasConflict = true;
$proposal = $slug->buildSlugForUniqueInPid($proposal, $state);
}
return new JsonResponse([
'hasConflicts' => $hasConflict,
'manual' => $values['manual'] ?? '',
'proposal' => $proposal,
]);
}
/**
* @throws \InvalidArgumentException
*/
protected function checkRequest(ServerRequestInterface $request): bool
{
$queryParameters = $request->getParsedBody() ?? [];
$expectedHash = $this->hashService->hmac(
implode(
'',
[
$queryParameters['tableName'],
$queryParameters['pageId'],
$queryParameters['recordId'],
$queryParameters['language'],
$queryParameters['fieldName'],
$queryParameters['command'],
$queryParameters['parentPageId'],
]
),
__CLASS__
);
if (!hash_equals($expectedHash, $queryParameters['signature'])) {
throw new \InvalidArgumentException(
'HMAC could not be verified',
1535137045
);
}
return true;
}
}
@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
/*
* 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 TYPO3\CMS\Backend\Controller;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Backend\Attribute\AsController;
use TYPO3\CMS\Backend\Routing\RouteResult;
use TYPO3\CMS\Core\Localization\JavaScriptLanguageDomainProvider;
/**
* @internal
*/
#[AsController]
final readonly class JavaScriptLanguageDomainController
{
public function __construct(
private JavaScriptLanguageDomainProvider $javaScriptLanguageDomainProvider,
) {}
public function getLanguageDomainAction(ServerRequestInterface $request): ResponseInterface
{
/** @var RouteResult $routing */
$routing = $request->getAttribute('routing');
$domain = $routing['domain'];
$locale = $routing['locale'];
return $this->javaScriptLanguageDomainProvider->createLanguageDomainResponse($domain, $locale);
}
}
@@ -0,0 +1,165 @@
<?php
declare(strict_types=1);
/*
* 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 TYPO3\CMS\Backend\Controller;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Backend\Attribute\AsController;
use TYPO3\CMS\Backend\Utility\BackendUtility;
use TYPO3\CMS\Core\Crypto\HashService;
use TYPO3\CMS\Core\Http\JsonResponse;
use TYPO3\CMS\Core\LinkHandling\Exception\UnknownLinkHandlerException;
use TYPO3\CMS\Core\LinkHandling\LinkService;
use TYPO3\CMS\Core\LinkHandling\TypoLinkCodecService;
use TYPO3\CMS\Core\Messaging\FlashMessage;
use TYPO3\CMS\Core\Messaging\FlashMessageService;
use TYPO3\CMS\Core\Page\JavaScriptModuleInstruction;
use TYPO3\CMS\Core\Type\ContextualFeedbackSeverity;
/**
* Extended controller for link browser
*/
#[AsController]
class LinkBrowserController extends AbstractLinkBrowserController
{
public function __construct(
protected readonly LinkService $linkService,
protected readonly TypoLinkCodecService $typoLinkCodecService,
protected readonly FlashMessageService $flashMessageService,
private readonly HashService $hashService,
) {}
public function getConfiguration(): array
{
$tsConfig = BackendUtility::getPagesTSconfig($this->getCurrentPageId());
return $tsConfig['TCEMAIN.']['linkHandler.']['page.']['configuration.'] ?? [];
}
/**
* Encode a typolink via ajax.
* This avoids implementing the encoding functionality again in JS for the browser.
*/
public function encodeTypoLink(ServerRequestInterface $request): ResponseInterface
{
$typoLinkParts = $request->getQueryParams();
if (isset($typoLinkParts['params'])) {
$typoLinkParts['additionalParams'] = $typoLinkParts['params'];
unset($typoLinkParts['params']);
}
$typoLink = $this->typoLinkCodecService->encode($typoLinkParts);
return new JsonResponse(['typoLink' => $typoLink]);
}
protected function initDocumentTemplate(): void
{
if (!$this->areFieldChangeFunctionsValid() && !$this->areFieldChangeFunctionsValid(true)) {
$this->parameters['fieldChangeFunc'] = [];
}
$this->pageRenderer->getJavaScriptRenderer()->addJavaScriptModuleInstruction(
JavaScriptModuleInstruction::create('@typo3/backend/form-engine-link-browser-adapter.js')
// @todo use a proper constructor when migrating to TypeScript
->invoke('setOnFieldChangeItems', $this->parameters['fieldChangeFunc'])
);
}
protected function getCurrentPageId(): int
{
$pageId = 0;
$browserParameters = $this->parameters;
if (isset($browserParameters['pid'])) {
$pageId = $browserParameters['pid'];
} elseif (isset($browserParameters['itemName'])) {
// parse data[<table>][<uid>]
if (preg_match('~data\[([^]]*)\]\[([^]]*)\]~', $browserParameters['itemName'], $matches)) {
$recordArray = BackendUtility::getRecord($matches['1'], $matches['2']);
if (is_array($recordArray)) {
$pageId = $recordArray['pid'];
}
}
}
return (int)BackendUtility::getRealPageId((string)$browserParameters['table'], (int)$browserParameters['uid'], (int)$pageId);
}
protected function initCurrentUrl(): void
{
$currentLink = isset($this->parameters['currentValue']) ? trim($this->parameters['currentValue']) : '';
/** @var array<string, string> $currentLinkParts */
$currentLinkParts = $this->typoLinkCodecService->decode($currentLink);
$currentLinkParts['params'] = $currentLinkParts['additionalParams'];
unset($currentLinkParts['additionalParams']);
if (!empty($currentLinkParts['url'])) {
try {
$data = $this->linkService->resolve($currentLinkParts['url']);
$currentLinkParts['type'] = $data['type'];
unset($data['type']);
$currentLinkParts['url'] = $data;
} catch (UnknownLinkHandlerException $e) {
$this->flashMessageService->getMessageQueueByIdentifier()->enqueue(
new FlashMessage(message: $e->getMessage(), severity: ContextualFeedbackSeverity::ERROR)
);
}
}
$this->currentLinkParts = $currentLinkParts;
parent::initCurrentUrl();
}
/**
* Determines whether submitted field change functions are valid
* and are coming from the system and not from an external abuse.
*
* @param bool $handleFlexformSections Whether to handle flexform sections differently
* @return bool Whether the submitted field change functions are valid
*/
protected function areFieldChangeFunctionsValid(bool $handleFlexformSections = false): bool
{
$result = false;
if (isset($this->parameters['fieldChangeFunc']) && is_array($this->parameters['fieldChangeFunc']) && isset($this->parameters['fieldChangeFuncHash'])) {
$matches = [];
$pattern = '#\\[el\\]\\[(([^]-]+-[^]-]+-)(idx\\d+-)([^]]+))\\]#i';
$fieldChangeFunctions = $this->parameters['fieldChangeFunc'];
// Special handling of flexform sections:
// Field change functions are modified in JavaScript, thus the hash is always invalid
if ($handleFlexformSections && preg_match($pattern, $this->parameters['itemName'], $matches)) {
$originalName = $matches[1];
$cleanedName = $matches[2] . $matches[4];
$fieldChangeFunctions = $this->strReplaceRecursively(
$originalName,
$cleanedName,
$fieldChangeFunctions
);
}
$result = hash_equals($this->hashService->hmac(serialize($fieldChangeFunctions), 'backend-link-browser'), $this->parameters['fieldChangeFuncHash']);
}
return $result;
}
protected function strReplaceRecursively(string $search, string $replace, array $array): array
{
foreach ($array as &$item) {
if (is_array($item)) {
$item = $this->strReplaceRecursively($search, $replace, $item);
} else {
$item = str_replace($search, $replace, $item);
}
}
return $array;
}
}
+125
View File
@@ -0,0 +1,125 @@
<?php
declare(strict_types=1);
/*
* 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 TYPO3\CMS\Backend\Controller;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Backend\Attribute\AsController;
use TYPO3\CMS\Core\Http\JsonResponse;
use TYPO3\CMS\Core\LinkHandling\LinkService;
use TYPO3\CMS\Core\Localization\LanguageService;
use TYPO3\CMS\Core\Messaging\FlashMessage;
use TYPO3\CMS\Core\Messaging\FlashMessageQueue;
use TYPO3\CMS\Core\Resource\Exception\InsufficientFileAccessPermissionsException;
use TYPO3\CMS\Core\Resource\Exception\InsufficientFolderAccessPermissionsException;
use TYPO3\CMS\Core\Resource\File;
use TYPO3\CMS\Core\Resource\Folder;
use TYPO3\CMS\Core\Resource\ResourceFactory;
use TYPO3\CMS\Core\Type\ContextualFeedbackSeverity;
/**
* @internal
*/
#[AsController]
final readonly class LinkController
{
public function __construct(
private LinkService $linkService,
private ResourceFactory $resourceFactory
) {}
public function resourceAction(ServerRequestInterface $request): ResponseInterface
{
$identifier = $request->getParsedBody()['identifier'] ?? null;
$resource = null;
try {
if ($identifier) {
$resource = $this->resourceFactory->retrieveFileOrFolderObject($identifier);
}
if (!$resource instanceof File && !$resource instanceof Folder) {
throw new \InvalidArgumentException('Resource must be a file or a folder', 1679039649);
}
if ($resource->getStorage()->isFallbackStorage()) {
throw new InsufficientFileAccessPermissionsException('You are not allowed to access files outside your storages', 1679039650);
}
if ($resource instanceof File) {
if (!$resource->checkActionPermission('read')) {
throw new InsufficientFileAccessPermissionsException('You are not allowed to access this file', 1779001351);
}
$parameters = [
'type' => LinkService::TYPE_FILE,
'file' => $resource,
];
}
if ($resource instanceof Folder) {
// Note: No explicit `$resource->checkActionPermission('read')` check here, as that would
// be a no-op since `ResourceStorage::getFolder()` calls `assureFolderReadPermission()`
// and throws `InsufficientFolderAccessPermissionsException`
$parameters = [
'type' => LinkService::TYPE_FOLDER,
'folder' => $resource,
];
}
$link = $this->linkService->asString($parameters);
} catch (InsufficientFileAccessPermissionsException|InsufficientFolderAccessPermissionsException $exception) {
$message = match ($exception->getCode()) {
1679039650 => $this->getLanguageService()->sL('LLL:EXT:backend/Resources/Private/Language/locallang_resource.xlf:ajax.error.message.resourceOutsideOfStorages'),
default => $this->getLanguageService()->sL('LLL:EXT:backend/Resources/Private/Language/locallang_resource.xlf:ajax.error.message.resourceNoPermissionRead'),
};
return new JsonResponse($this->getResponseData(false, $message));
} catch (\Exception $exception) {
$message = match ($exception->getCode()) {
1679039649 => $this->getLanguageService()->sL('LLL:EXT:backend/Resources/Private/Language/locallang_resource.xlf:ajax.error.message.resourceNotFileOrFolder'),
default => $exception->getMessage(),
};
return new JsonResponse($this->getResponseData(false, $message));
}
return new JsonResponse($this->getResponseData(true, null, $link));
}
/**
* Prepare response data for a JSON response
*/
private function getResponseData(bool $success, ?string $message = null, ?string $link = null): array
{
$flashMessageQueue = new FlashMessageQueue('backend');
if ($message) {
$flashMessageQueue->enqueue(
new FlashMessage(
$message,
$this->getLanguageService()->sL('LLL:EXT:backend/Resources/Private/Language/locallang_resource.xlf:ajax.' . ($success ? 'success' : 'error')),
$success ? ContextualFeedbackSeverity::OK : ContextualFeedbackSeverity::ERROR
)
);
}
return [
'success' => $success,
'status' => $flashMessageQueue,
'link' => $link,
];
}
private function getLanguageService(): LanguageService
{
return $GLOBALS['LANG'];
}
}
+119
View File
@@ -0,0 +1,119 @@
<?php
declare(strict_types=1);
/*
* 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 TYPO3\CMS\Backend\Controller;
use Psr\EventDispatcher\EventDispatcherInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Backend\Attribute\AsController;
use TYPO3\CMS\Backend\Search\Event\BeforeLiveSearchFormIsBuiltEvent;
use TYPO3\CMS\Backend\Search\LiveSearch\SearchDemand\SearchDemand;
use TYPO3\CMS\Backend\Search\LiveSearch\SearchRepository;
use TYPO3\CMS\Backend\View\BackendViewFactory;
use TYPO3\CMS\Core\Http\JsonResponse;
use TYPO3\CMS\Core\Http\Response;
use TYPO3\CMS\Core\Localization\LanguageService;
use TYPO3\CMS\Core\Pagination\SlidingWindowPagination;
/**
* Returns the results for any live searches, e.g. in the toolbar
* @internal This class is a specific Backend controller implementation and is not considered part of the Public TYPO3 API.
*/
#[AsController]
readonly class LiveSearchController
{
public function __construct(
protected BackendViewFactory $backendViewFactory,
protected SearchRepository $searchService,
protected EventDispatcherInterface $eventDispatcher,
) {}
/**
* Processes all AJAX calls and sends back a JSON object
*/
public function searchAction(ServerRequestInterface $request): ResponseInterface
{
$mutableSearchDemand = SearchDemand::fromRequest($request);
if ($mutableSearchDemand->getQuery() === '') {
return new Response('', 400, [], 'Argument "query" is missing or empty.');
}
$results = $this->searchService->find($mutableSearchDemand);
$pagination = new SlidingWindowPagination($results, 15);
$response = [
'pagination' => [
'itemsPerPage' => SearchDemand::DEFAULT_LIMIT,
'currentPage' => $pagination->getPaginator()->getCurrentPageNumber(),
'firstPage' => $pagination->getFirstPageNumber(),
'lastPage' => $pagination->getLastPageNumber(),
'allPageNumbers' => $pagination->getAllPageNumbers(),
'previousPageNumber' => $pagination->getPreviousPageNumber(),
'nextPageNumber' => $pagination->getNextPageNumber(),
'hasMorePages' => $pagination->getHasMorePages(),
'hasLessPages' => $pagination->getHasLessPages(),
],
'results' => $results->getPaginatedItems(),
];
return new JsonResponse($response);
}
public function formAction(ServerRequestInterface $request): ResponseInterface
{
$hints = [
'LLL:EXT:core/Resources/Private/Language/locallang_misc.xlf:liveSearch_helpDescriptionPages',
'LLL:EXT:core/Resources/Private/Language/locallang_misc.xlf:liveSearch_helpDescriptionContent',
'LLL:EXT:core/Resources/Private/Language/locallang_misc.xlf:liveSearch_help.shortcutOpen',
];
$event = $this->eventDispatcher->dispatch(
new BeforeLiveSearchFormIsBuiltEvent($hints, $request)
);
$hints = $event->getHints();
$searchDemand = $event->getSearchDemand();
$randomHintKey = array_rand($hints);
$additionalViewData = $event->getAdditionalViewData();
$searchProviders = $this->searchService->getSearchProviderState($searchDemand);
$activeOptions = 0;
// `isActive` is the result of `in_array()`, which returns a `bool`.
$activeOptions += count(array_filter($searchProviders, fn(array $searchProviderOption): bool => $searchProviderOption['isActive']));
$view = $this->backendViewFactory->create($request, ['typo3/cms-backend']);
if ($additionalViewData !== []) {
$view->assignMultiple($additionalViewData);
}
$view->assignMultiple([
'searchDemand' => $searchDemand,
'hint' => $this->getLanguageService()->sL($hints[$randomHintKey]),
'searchProviders' => $searchProviders,
'activeOptions' => $activeOptions,
]);
$response = new Response();
$response->getBody()->write($view->render('LiveSearch/Form'));
return $response;
}
private function getLanguageService(): LanguageService
{
return $GLOBALS['LANG'];
}
}
+298
View File
@@ -0,0 +1,298 @@
<?php
declare(strict_types=1);
/*
* 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 TYPO3\CMS\Backend\Controller;
use Psr\EventDispatcher\EventDispatcherInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Symfony\Component\HttpFoundation\Cookie;
use TYPO3\CMS\Backend\Attribute\AsController;
use TYPO3\CMS\Backend\LoginProvider\Event\ModifyPageLayoutOnLoginProviderSelectionEvent;
use TYPO3\CMS\Backend\LoginProvider\LoginProviderInterface;
use TYPO3\CMS\Backend\LoginProvider\LoginProviderResolver;
use TYPO3\CMS\Backend\Routing\Exception\RouteNotFoundException;
use TYPO3\CMS\Backend\Routing\RouteRedirect;
use TYPO3\CMS\Backend\Routing\UriBuilder;
use TYPO3\CMS\Backend\Template\PageRendererBackendSetupTrait;
use TYPO3\CMS\Backend\View\AuthenticationStyleInformation;
use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
use TYPO3\CMS\Core\Configuration\ExtensionConfiguration;
use TYPO3\CMS\Core\Configuration\Features;
use TYPO3\CMS\Core\Context\Context;
use TYPO3\CMS\Core\Context\SecurityAspect;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\FormProtection\BackendFormProtection;
use TYPO3\CMS\Core\FormProtection\FormProtectionFactory;
use TYPO3\CMS\Core\Http\JsonResponse;
use TYPO3\CMS\Core\Http\PropagateResponseException;
use TYPO3\CMS\Core\Http\RedirectResponse;
use TYPO3\CMS\Core\Information\Typo3Information;
use TYPO3\CMS\Core\Localization\LanguageService;
use TYPO3\CMS\Core\Localization\Locales;
use TYPO3\CMS\Core\Page\PageRenderer;
use TYPO3\CMS\Core\Routing\BackendEntryPointResolver;
use TYPO3\CMS\Core\Security\RequestToken;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Core\View\ViewFactoryData;
use TYPO3\CMS\Core\View\ViewFactoryInterface;
/**
* Controller responsible for rendering the TYPO3 Backend login form.
*
* @internal This class is a specific Backend controller implementation and is not considered part of the Public TYPO3 API.
* @todo: The central template rendering magic needs an overhaul: Currently, LoginProviderInterface has to
* be implemented, which retrieves a "prepared" view with tons of variable used by the default Layout "Login.fluid.html".
* Single LoginProviderInterface then set their template path ("Login/UserPassLoginForm" in UsernamePasswordLoginProvider),
* which sets Login.fluid.html as layout in its template to then get its sections "loginFormFields" and "ResetPassword"
* rendered. This strategy is a major mess and needs to be turned around somehow.
* Note there is also this BE "relogin" and "login refresh" foo with lots of attached JS magic that
* should either be streamlined to actually work, or (preferred) be thrown away.
*/
#[AsController]
readonly class LoginController
{
use PageRendererBackendSetupTrait;
public function __construct(
protected Typo3Information $typo3Information,
protected EventDispatcherInterface $eventDispatcher,
protected PageRenderer $pageRenderer,
protected UriBuilder $uriBuilder,
protected Features $features,
protected Context $context,
protected LoginProviderResolver $loginProviderResolver,
protected ExtensionConfiguration $extensionConfiguration,
protected BackendEntryPointResolver $backendEntryPointResolver,
protected FormProtectionFactory $formProtectionFactory,
protected Locales $locales,
protected ConnectionPool $connectionPool,
protected AuthenticationStyleInformation $authenticationStyleInformation,
protected ViewFactoryInterface $viewFactory,
) {}
/**
* Injects the request and response objects for the current request or subrequest
* As this controller goes only through the main() method, it is rather simple for now
*/
public function formAction(ServerRequestInterface $request): ResponseInterface
{
return $this->createLoginLogout($request, (bool)($request->getParsedBody()['loginRefresh'] ?? $request->getQueryParams()['loginRefresh'] ?? false));
}
/**
* Calls the main function but with loginRefresh enabled at any time
*/
public function refreshAction(ServerRequestInterface $request): ResponseInterface
{
return $this->createLoginLogout($request, true);
}
/**
* @param bool $loginRefresh The backend triggers this with this value set when the login is
* close to being expired and the form needs to be redrawn.
* @throws PropagateResponseException
* @throws RouteNotFoundException
*/
protected function createLoginLogout(ServerRequestInterface $request, bool $loginRefresh): ResponseInterface
{
$backendUser = $this->getBackendUserAuthentication();
if (!empty($backendUser->user['uid'])) {
// If BE user is logged in, redirect to backend. Also handles "refresh" foo.
$this->checkRedirect($request, $loginRefresh);
}
$languageService = $this->getLanguageService();
if (empty($backendUser->user['uid'])) {
// If no user is logged in, initialize LanguageService with preferred browser language and set the
// language to the backend user object, so labels in fluid views are translated.
$httpAcceptLanguage = $request->getServerParams()['HTTP_ACCEPT_LANGUAGE'] ?? '';
$preferredBrowserLanguage = $this->locales->getPreferredClientLanguage($httpAcceptLanguage);
$languageService->init($this->locales->createLocale($preferredBrowserLanguage));
$backendUser->user['lang'] = $preferredBrowserLanguage;
}
if (($backgroundImageStyles = $this->authenticationStyleInformation->getBackgroundImageStyles($request)) !== '') {
$this->pageRenderer->addCssInlineBlock('loginBackgroundImage', $backgroundImageStyles, null, false, true);
}
if (($highlightColorStyles = $this->authenticationStyleInformation->getHighlightColorStyles()) !== '') {
$this->pageRenderer->addCssInlineBlock('loginHighlightColor', $highlightColorStyles, null, false, true);
}
$loginProviderIdentifier = $this->loginProviderResolver->resolveLoginProviderIdentifierFromRequest($request, 'be_lastLoginProvider');
if (empty($backendUser->user['uid'])) {
// Show login form
$action = 'login';
$formActionUrl = $this->uriBuilder->buildUriWithRedirect('login', ['loginProvider' => $loginProviderIdentifier], RouteRedirect::createFromRequest($request));
} else {
// Show logout form
$action = 'logout';
$formActionUrl = $this->uriBuilder->buildUriFromRoute('logout');
}
$forgotPasswordUrl = $this->uriBuilder->buildUriWithRedirect('password_forget', ['loginProvider' => $loginProviderIdentifier], RouteRedirect::createFromRequest($request));
$viewVariables = [
'copyright' => $this->typo3Information->getCopyrightNotice(),
'loginFootnote' => $this->authenticationStyleInformation->getFooterNote(),
'referrerCheckEnabled' => $this->features->isFeatureEnabled('security.backend.enforceReferrer'),
'loginUrl' => (string)$request->getUri(),
'loginProviderIdentifier' => $loginProviderIdentifier,
'backendUser' => $backendUser->user,
'hasLoginError' => $this->isLoginInProgress($request),
'action' => $action,
'formActionUrl' => $formActionUrl,
'requestTokenName' => RequestToken::PARAM_NAME,
'requestTokenValue' => $this->provideRequestTokenJwt(),
'forgetPasswordUrl' => $forgotPasswordUrl,
'loginRefresh' => $loginRefresh,
'loginProviders' => $this->loginProviderResolver->getLoginProviders(),
'loginNewsItems' => $this->getSystemNews(),
];
$this->setUpBasicPageRendererForBackend($this->pageRenderer, $this->extensionConfiguration, $request, $languageService);
$this->pageRenderer->setTitle('TYPO3 CMS Login: ' . ($GLOBALS['TYPO3_CONF_VARS']['SYS']['sitename'] ?? ''));
$loginProviderConfiguration = $this->loginProviderResolver->getLoginProviderConfigurationByIdentifier($loginProviderIdentifier);
$loginProvider = GeneralUtility::makeInstance($loginProviderConfiguration['provider']);
if (!$loginProvider instanceof LoginProviderInterface) {
throw new \RuntimeException($loginProviderConfiguration['provider'] . ' must implement LoginProviderInterface', 1724772171);
}
$viewFactoryData = new ViewFactoryData(
templateRootPaths: ['EXT:backend/Resources/Private/Templates'],
partialRootPaths: ['EXT:backend/Resources/Private/Partials'],
layoutRootPaths: ['EXT:backend/Resources/Private/Layouts'],
request: $request,
);
$view = $this->viewFactory->create($viewFactoryData);
$view->assignMultiple($viewVariables);
$this->eventDispatcher->dispatch(new ModifyPageLayoutOnLoginProviderSelectionEvent($view, $request));
$templateFile = $loginProvider->modifyView($request, $view);
$content = $view->render($templateFile);
$this->pageRenderer->setBodyContent('<body>' . $content);
$response = $this->pageRenderer->renderResponse($request);
return $this->appendLoginProviderCookie($request, $response);
}
/**
* Returns a new request-token value, which is signed by a new nonce value (the nonce is sent
* as cookie automatically in `RequestTokenMiddleware` since it is created via the `NoncePool`).
*/
public function requestTokenAction(ServerRequestInterface $request): ResponseInterface
{
return new JsonResponse([
'headerName' => RequestToken::HEADER_NAME,
'requestToken' => $this->provideRequestTokenJwt(),
]);
}
/**
* @throws PropagateResponseException
*/
protected function checkRedirect(ServerRequestInterface $request, bool $loginRefresh): void
{
$formProtection = $this->formProtectionFactory->createFromRequest($request);
if (!$formProtection instanceof BackendFormProtection) {
throw new \RuntimeException('The Form Protection retrieved does not match the expected one.', 1432080411);
}
if ($loginRefresh) {
// Triggering `TYPO3/CMS/Backend/LoginRefresh` module happens in JS `TYPO3/CMS/Backend/Login`
$formProtection->setSessionTokenFromRegistry();
$formProtection->persistSessionToken();
} else {
$formProtection->storeSessionTokenInRegistry();
// @todo: Consolidate RouteDispatcher::evaluateReferrer() when changing 'main' to something different
$redirectToURL = (string)$this->uriBuilder->buildUriWithRedirect('main', [], RouteRedirect::createFromRequest($request));
throw new PropagateResponseException(new RedirectResponse($redirectToURL, 303), 1724705833);
}
}
/**
* If a login provider was chosen in the previous request, which is not the default provider,
* it is stored in a Cookie and appended to the HTTP Response.
*/
protected function appendLoginProviderCookie(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
{
$normalizedParams = $request->getAttribute('normalizedParams');
$loginProviderIdentifier = $this->loginProviderResolver->resolveLoginProviderIdentifierFromRequest($request, 'be_lastLoginProvider');
if ($loginProviderIdentifier === $this->loginProviderResolver->getPrimaryLoginProviderIdentifier()) {
return $response;
}
$cookie = new Cookie(
'be_lastLoginProvider',
$loginProviderIdentifier,
$GLOBALS['EXEC_TIME'] + 7776000, // 90 days
$this->backendEntryPointResolver->getPathFromRequest($request),
'',
// Use the secure option when the current request is served by a secure connection
$normalizedParams->isHttps(),
true,
false,
Cookie::SAMESITE_STRICT
);
return $response->withAddedHeader('Set-Cookie', $cookie->__toString());
}
/**
* Gets news as array from sys_news and converts them into a
* format suitable for showing them at the login screen.
*/
protected function getSystemNews(): array
{
$systemNews = [];
$queryResult = $this->connectionPool
->getQueryBuilderForTable('sys_news')
->select('uid', 'title', 'content', 'crdate')
->from('sys_news')
->orderBy('crdate', 'DESC')
->executeQuery();
while ($row = $queryResult->fetchAssociative()) {
$systemNews[] = [
'uid' => $row['uid'],
'date' => $row['crdate'] ? date($GLOBALS['TYPO3_CONF_VARS']['SYS']['ddmmyy'], (int)$row['crdate']) : '',
'header' => $row['title'],
'content' => $row['content'],
];
}
return $systemNews;
}
/**
* Checks if login credentials have been submitted
*/
protected function isLoginInProgress(ServerRequestInterface $request): bool
{
// @todo: Restrict to POST?!
// Value of forms submit button for login. If set, the login button was pressed.
$submitValue = $request->getParsedBody()['commandLI'] ?? $request->getQueryParams()['commandLI'] ?? '';
$username = $request->getParsedBody()['username'] ?? $request->getQueryParams()['username'] ?? null;
return !empty($username) || !empty($submitValue);
}
protected function provideRequestTokenJwt(): string
{
$nonce = SecurityAspect::provideIn($this->context)->provideNonce();
return RequestToken::create('core/user-auth/be')->toHashSignedJwt($nonce);
}
protected function getLanguageService(): LanguageService
{
return $GLOBALS['LANG'];
}
protected function getBackendUserAuthentication(): BackendUserAuthentication
{
return $GLOBALS['BE_USER'];
}
}
+86
View File
@@ -0,0 +1,86 @@
<?php
declare(strict_types=1);
/*
* 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 TYPO3\CMS\Backend\Controller;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Backend\Attribute\AsController;
use TYPO3\CMS\Backend\Routing\UriBuilder;
use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
use TYPO3\CMS\Core\FormProtection\BackendFormProtection;
use TYPO3\CMS\Core\FormProtection\FormProtectionFactory;
use TYPO3\CMS\Core\Http\RedirectResponse;
use TYPO3\CMS\Core\SysLog\Action\Login as SystemLogLoginAction;
use TYPO3\CMS\Core\SysLog\Error as SystemLogErrorClassification;
use TYPO3\CMS\Core\SysLog\Type as SystemLogType;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Script Class for logging a user out.
* Does not display any content, just calls the logout-function for the current user and then makes a redirect.
* @internal This class is a specific Backend controller implementation and is not considered part of the Public TYPO3 API.
*/
#[AsController]
readonly class LogoutController
{
public function __construct(
protected UriBuilder $uriBuilder,
protected FormProtectionFactory $formProtectionFactory
) {}
/**
* Injects the request object for the current request or subrequest
* As this controller goes only through the main() method, it is rather simple for now
* This will be split up in an abstract controller once proper routing/dispatcher is in place.
*
* @param ServerRequestInterface $request the current request
* @return ResponseInterface the response with the content
*/
public function logoutAction(ServerRequestInterface $request): ResponseInterface
{
$this->processLogout($request);
$redirectUrl = $request->getParsedBody()['redirect'] ?? $request->getQueryParams()['redirect'] ?? '';
$redirectUrl = GeneralUtility::sanitizeLocalUrl($redirectUrl, $request);
if (empty($redirectUrl)) {
$redirectUrl = (string)$this->uriBuilder->buildUriFromRoute('login', [], UriBuilder::ABSOLUTE_URL);
}
return new RedirectResponse(GeneralUtility::locationHeaderUrl($redirectUrl, $request), 303);
}
/**
* Performs the logout processing
*/
protected function processLogout(ServerRequestInterface $request): void
{
if (empty($this->getBackendUser()->user['username'])) {
return;
}
// Logout written to log
$this->getBackendUser()->writelog(SystemLogType::LOGIN, SystemLogLoginAction::LOGOUT, SystemLogErrorClassification::MESSAGE, null, 'User %s logged out from TYPO3 Backend', [$this->getBackendUser()->user['username']]);
/** @var BackendFormProtection $backendFormProtection */
$backendFormProtection = $this->formProtectionFactory->createFromRequest($request);
$backendFormProtection->removeSessionTokenFromRegistry();
$this->getBackendUser()->logoff();
}
protected function getBackendUser(): BackendUserAuthentication
{
return $GLOBALS['BE_USER'];
}
}
+216
View File
@@ -0,0 +1,216 @@
<?php
declare(strict_types=1);
/*
* 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 TYPO3\CMS\Backend\Controller;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Backend\Attribute\AsController;
use TYPO3\CMS\Core\Authentication\AbstractUserAuthentication;
use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
use TYPO3\CMS\Core\Authentication\Mfa\MfaProviderPropertyManager;
use TYPO3\CMS\Core\Authentication\Mfa\MfaProviderRegistry;
use TYPO3\CMS\Core\Http\JsonResponse;
use TYPO3\CMS\Core\Localization\LanguageService;
use TYPO3\CMS\Core\Messaging\FlashMessage;
use TYPO3\CMS\Core\Messaging\FlashMessageQueue;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Frontend\Authentication\FrontendUserAuthentication;
/**
* Controller to manipulate MFA providers via AJAX in the backend
*
* @internal This class is a specific Backend controller implementation and is not considered part of the Public TYPO3 API.
*/
#[AsController]
class MfaAjaxController
{
private const array ALLOWED_ACTIONS = ['deactivate'];
protected MfaProviderRegistry $mfaProviderRegistry;
public function __construct(MfaProviderRegistry $mfaProviderRegistry)
{
$this->mfaProviderRegistry = $mfaProviderRegistry;
}
/**
* Main entry point, checking prerequisite and dispatching to the requested action
*/
public function handleRequest(ServerRequestInterface $request): ResponseInterface
{
$action = (string)($request->getQueryParams()['action'] ?? $request->getParsedBody()['action'] ?? '');
if (!in_array($action, self::ALLOWED_ACTIONS, true)) {
return new JsonResponse($this->getResponseData(false, $this->getLanguageService()->sL('LLL:EXT:backend/Resources/Private/Language/locallang_mfa.xlf:ajax.invalidRequest')));
}
$userId = (int)($request->getParsedBody()['userId'] ?? 0);
$tableName = (string)($request->getParsedBody()['tableName'] ?? '');
if (!$userId || !in_array($tableName, ['be_users', 'fe_users'], true)) {
return new JsonResponse($this->getResponseData(false, $this->getLanguageService()->sL('LLL:EXT:backend/Resources/Private/Language/locallang_mfa.xlf:ajax.invalidRequest')));
}
$user = $this->initializeUser($userId, $tableName);
if (!$this->isAllowedToPerformAction($action, $user)) {
return new JsonResponse($this->getResponseData(false, $this->getLanguageService()->sL('LLL:EXT:backend/Resources/Private/Language/locallang_mfa.xlf:ajax.insufficientPermissions')));
}
return new JsonResponse($this->{$action . 'Action'}($request, $user));
}
/**
* Deactivate MFA providers
* If the request contains a provider, it will be deactivated.
* Otherwise all active providers are deactivated.
*/
protected function deactivateAction(ServerRequestInterface $request, AbstractUserAuthentication $user): array
{
$lang = $this->getLanguageService();
$userName = $user->getUserName() ?? '';
$providerToDeactivate = (string)($request->getParsedBody()['provider'] ?? '');
if ($providerToDeactivate === '') {
// In case no provider is given, try to deactivate all active providers
$providersToDeactivate = $this->mfaProviderRegistry->getActiveProviders($user);
if ($providersToDeactivate === []) {
return $this->getResponseData(
false,
$lang->sL('LLL:EXT:backend/Resources/Private/Language/locallang_mfa.xlf:ajax.deactivate.providersNotDeactivated'),
$user
);
}
foreach ($providersToDeactivate as $provider) {
$propertyManager = MfaProviderPropertyManager::create($provider, $user);
if (!$provider->deactivate($request, $propertyManager)) {
return $this->getResponseData(
false,
sprintf($lang->sL('LLL:EXT:backend/Resources/Private/Language/locallang_mfa.xlf:ajax.deactivate.providerNotDeactivated'), $lang->sL($provider->getTitle())),
$user
);
}
}
return $this->getResponseData(
true,
sprintf($lang->sL('LLL:EXT:backend/Resources/Private/Language/locallang_mfa.xlf:ajax.deactivate.providersDeactivated'), $userName),
$user
);
}
if (!$this->mfaProviderRegistry->hasProvider($providerToDeactivate)) {
return $this->getResponseData(
false,
sprintf($lang->sL('LLL:EXT:backend/Resources/Private/Language/locallang_mfa.xlf:ajax.deactivate.providerNotFound'), $providerToDeactivate),
$user
);
}
$provider = $this->mfaProviderRegistry->getProvider($providerToDeactivate);
$propertyManager = MfaProviderPropertyManager::create($provider, $user);
if (!$provider->isActive($propertyManager) || !$provider->deactivate($request, $propertyManager)) {
return $this->getResponseData(
false,
sprintf($lang->sL('LLL:EXT:backend/Resources/Private/Language/locallang_mfa.xlf:ajax.deactivate.providerNotDeactivated'), $lang->sL($provider->getTitle())),
$user
);
}
return $this->getResponseData(
true,
sprintf($lang->sL('LLL:EXT:backend/Resources/Private/Language/locallang_mfa.xlf:ajax.deactivate.providerDeactivated'), $lang->sL($provider->getTitle()), $userName),
$user
);
}
/**
* Initialize a user based on the table name
*/
protected function initializeUser(int $userId, string $tableName): AbstractUserAuthentication
{
$user = $tableName === 'be_users'
? GeneralUtility::makeInstance(BackendUserAuthentication::class)
: GeneralUtility::makeInstance(FrontendUserAuthentication::class);
$user->enablecolumns = ['deleted' => true];
$user->setBeUserByUid($userId);
return $user;
}
/**
* Prepare response data for a JSON response
*/
protected function getResponseData(bool $success, string $message, ?AbstractUserAuthentication $user = null): array
{
$flashMessageQueue = new FlashMessageQueue('backend');
$flashMessageQueue->enqueue(
new FlashMessage(
$message,
$this->getLanguageService()->sL('LLL:EXT:backend/Resources/Private/Language/locallang_mfa.xlf:ajax.' . ($success ? 'success' : 'error'))
)
);
$payload = [
'success' => $success,
'status' => $flashMessageQueue,
];
if ($user !== null) {
$payload['remaining'] = count($this->mfaProviderRegistry->getActiveProviders($user));
}
return $payload;
}
/**
* Check if the current logged in user is allowed to perform
* the requested action on the selected user.
*/
protected function isAllowedToPerformAction(string $action, AbstractUserAuthentication $user): bool
{
if ($action === 'deactivate') {
$currentBackendUser = $this->getBackendUser();
// Only admins are allowed to deactivate providers
if (!$currentBackendUser->isAdmin()) {
return false;
}
// Providers from system maintainers can only be deactivated by system maintainers.
// However, this check is only necessary if the target is a backend user.
if (($user instanceof BackendUserAuthentication)
&& $user->isSystemMaintainer(true)
&& !$this->getBackendUser()->isSystemMaintainer()
) {
return false;
}
return true;
}
return false;
}
protected function getBackendUser(): BackendUserAuthentication
{
return $GLOBALS['BE_USER'];
}
protected function getLanguageService(): LanguageService
{
return $GLOBALS['LANG'];
}
}
@@ -0,0 +1,384 @@
<?php
declare(strict_types=1);
/*
* 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 TYPO3\CMS\Backend\Controller;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\UriInterface;
use TYPO3\CMS\Backend\Attribute\AsController;
use TYPO3\CMS\Backend\Routing\UriBuilder;
use TYPO3\CMS\Backend\Template\Components\ButtonBar;
use TYPO3\CMS\Backend\Template\Components\ComponentFactory;
use TYPO3\CMS\Backend\Template\ModuleTemplate;
use TYPO3\CMS\Backend\Template\ModuleTemplateFactory;
use TYPO3\CMS\Core\Authentication\Mfa\MfaProviderManifestInterface;
use TYPO3\CMS\Core\Authentication\Mfa\MfaProviderPropertyManager;
use TYPO3\CMS\Core\Authentication\Mfa\MfaViewType;
use TYPO3\CMS\Core\Http\HtmlResponse;
use TYPO3\CMS\Core\Http\RedirectResponse;
use TYPO3\CMS\Core\Imaging\IconFactory;
use TYPO3\CMS\Core\Messaging\FlashMessage;
use TYPO3\CMS\Core\Messaging\FlashMessageService;
use TYPO3\CMS\Core\Type\ContextualFeedbackSeverity;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Controller to configure MFA providers in the backend
*
* @internal This class is a specific Backend controller implementation and is not considered part of the Public TYPO3 API.
*/
#[AsController]
class MfaConfigurationController extends AbstractMfaController
{
protected array $allowedActions = ['overview', 'setup', 'activate', 'deactivate', 'unlock', 'edit', 'save'];
private array $providerActionsWhenInactive = ['setup', 'activate'];
private array $providerActionsWhenActive = ['deactivate', 'unlock', 'edit', 'save'];
protected ModuleTemplate $view;
public function __construct(
protected readonly ComponentFactory $componentFactory,
protected readonly IconFactory $iconFactory,
protected readonly UriBuilder $uriBuilder,
protected readonly ModuleTemplateFactory $moduleTemplateFactory,
protected readonly FlashMessageService $flashMessageService,
) {}
/**
* Main entry point, checking prerequisite, initializing and setting
* up the view and finally dispatching to the requested action.
*/
public function handleRequest(ServerRequestInterface $request): ResponseInterface
{
$this->initializeMfaConfiguration();
$this->view = $this->moduleTemplateFactory->create($request);
$action = (string)($request->getQueryParams()['action'] ?? $request->getParsedBody()['action'] ?? 'overview');
if (!$this->isActionAllowed($action)) {
return new HtmlResponse('Action not allowed', 400);
}
$mfaProvider = null;
$identifier = (string)($request->getQueryParams()['identifier'] ?? $request->getParsedBody()['identifier'] ?? '');
// Check if given identifier is valid
if ($this->isValidIdentifier($identifier)) {
$mfaProvider = $this->mfaProviderRegistry->getProvider($identifier);
}
// All actions expect "overview" require a provider to deal with.
// If non is found at this point, initiate a redirect to the overview.
if ($mfaProvider === null && $action !== 'overview') {
$this->addFlashMessage($this->getLanguageService()->sL('LLL:EXT:backend/Resources/Private/Language/locallang_mfa.xlf:providerNotFound'), '', ContextualFeedbackSeverity::ERROR);
return new RedirectResponse($this->getActionUri('overview'));
}
// If a valid provider is given, check if the requested action can be performed on this provider
if ($mfaProvider !== null) {
$isProviderActive = $mfaProvider->isActive(
MfaProviderPropertyManager::create($mfaProvider, $this->getBackendUser())
);
// Some actions require the provider to be inactive
if ($isProviderActive && in_array($action, $this->providerActionsWhenInactive, true)) {
$this->addFlashMessage($this->getLanguageService()->sL('LLL:EXT:backend/Resources/Private/Language/locallang_mfa.xlf:providerActive'), '', ContextualFeedbackSeverity::ERROR);
return new RedirectResponse($this->getActionUri('overview'));
}
// Some actions require the provider to be active
if (!$isProviderActive && in_array($action, $this->providerActionsWhenActive, true)) {
$this->addFlashMessage($this->getLanguageService()->sL('LLL:EXT:backend/Resources/Private/Language/locallang_mfa.xlf:providerNotActive'), '', ContextualFeedbackSeverity::ERROR);
return new RedirectResponse($this->getActionUri('overview'));
}
}
switch ($action) {
case 'overview':
return $this->overviewAction($request);
case 'setup':
case 'edit':
case 'activate':
case 'deactivate':
case 'unlock':
case 'save':
return $this->{$action . 'Action'}($request, $mfaProvider);
default:
return new HtmlResponse('Action not allowed', 400);
}
}
/**
* Setup the overview with all available MFA providers
*/
protected function overviewAction(ServerRequestInterface $request): ResponseInterface
{
$this->addOverviewButtons($request);
$this->view->assignMultiple([
'providers' => $this->allowedProviders,
'defaultProvider' => $this->getDefaultProviderIdentifier(),
'recommendedProvider' => $this->getRecommendedProviderIdentifier(),
'setupRequired' => $this->mfaRequired && !$this->mfaProviderRegistry->hasActiveProviders($this->getBackendUser()),
]);
return $this->view->renderResponse('Mfa/Overview');
}
/**
* Render form to setup a provider by using provider specific content
*/
protected function setupAction(ServerRequestInterface $request, MfaProviderManifestInterface $mfaProvider): ResponseInterface
{
$this->addFormButtons();
$propertyManager = MfaProviderPropertyManager::create($mfaProvider, $this->getBackendUser());
$providerResponse = $mfaProvider->handleRequest($request, $propertyManager, MfaViewType::SETUP);
$this->view->assignMultiple([
'provider' => $mfaProvider,
'providerContent' => $providerResponse->getBody(),
]);
return $this->view->renderResponse('Mfa/Setup');
}
/**
* Handle activate request, receiving from the setup view
* by forwarding the request to the appropriate provider.
* Furthermore, add the provider as default provider in case
* it is the recommended provider for this user, or no default
* provider is yet defined the newly activated provider is allowed
* to be a default provider and there are no other providers which
* would suite as default provider.
*/
protected function activateAction(ServerRequestInterface $request, MfaProviderManifestInterface $mfaProvider): ResponseInterface
{
$backendUser = $this->getBackendUser();
$isRecommendedProvider = $this->getRecommendedProviderIdentifier() === $mfaProvider->getIdentifier();
$propertyManager = MfaProviderPropertyManager::create($mfaProvider, $backendUser);
$languageService = $this->getLanguageService();
// Check whether activation operation was successful and the provider is now active.
if (!$mfaProvider->activate($request, $propertyManager) || !$mfaProvider->isActive($propertyManager)) {
$this->addFlashMessage(sprintf($languageService->sL('LLL:EXT:backend/Resources/Private/Language/locallang_mfa.xlf:activate.failure'), $languageService->sL($mfaProvider->getTitle())), '', ContextualFeedbackSeverity::ERROR);
return new RedirectResponse($this->getActionUri('setup', ['identifier' => $mfaProvider->getIdentifier()]));
}
if ($isRecommendedProvider
|| (
$this->getDefaultProviderIdentifier() === ''
&& $mfaProvider->isDefaultProviderAllowed()
&& !$this->hasSuitableDefaultProviders([$mfaProvider->getIdentifier()])
)
) {
$this->setDefaultProvider($mfaProvider);
}
// If this is the first activated provider, the user has logged in without being required
// to pass the MFA challenge. Therefore, no session entry exists. To prevent the challenge
// from showing up after the activation we need to set the session data here.
if (!(bool)($backendUser->getSessionData('mfa') ?? false)) {
$backendUser->setSessionData('mfa', true);
}
$this->addFlashMessage(sprintf($languageService->sL('LLL:EXT:backend/Resources/Private/Language/locallang_mfa.xlf:activate.success'), $languageService->sL($mfaProvider->getTitle())), '', ContextualFeedbackSeverity::OK);
return new RedirectResponse($this->getActionUri('overview'));
}
/**
* Handle deactivate request by forwarding the request to the
* appropriate provider. Also remove the provider as default
* provider from user UC, if set.
*/
protected function deactivateAction(ServerRequestInterface $request, MfaProviderManifestInterface $mfaProvider): ResponseInterface
{
$propertyManager = MfaProviderPropertyManager::create($mfaProvider, $this->getBackendUser());
$languageService = $this->getLanguageService();
if (!$mfaProvider->deactivate($request, $propertyManager)) {
$this->addFlashMessage(sprintf($languageService->sL('LLL:EXT:backend/Resources/Private/Language/locallang_mfa.xlf:deactivate.failure'), $languageService->sL($mfaProvider->getTitle())), '', ContextualFeedbackSeverity::ERROR);
} else {
if ($this->isDefaultProvider($mfaProvider)) {
$this->removeDefaultProvider();
}
$this->addFlashMessage(sprintf($languageService->sL('LLL:EXT:backend/Resources/Private/Language/locallang_mfa.xlf:deactivate.success'), $languageService->sL($mfaProvider->getTitle())), '', ContextualFeedbackSeverity::OK);
}
return new RedirectResponse($this->getActionUri('overview'));
}
/**
* Handle unlock request by forwarding the request to the appropriate provider
*/
protected function unlockAction(ServerRequestInterface $request, MfaProviderManifestInterface $mfaProvider): ResponseInterface
{
$propertyManager = MfaProviderPropertyManager::create($mfaProvider, $this->getBackendUser());
$languageService = $this->getLanguageService();
if (!$mfaProvider->unlock($request, $propertyManager)) {
$this->addFlashMessage(sprintf($languageService->sL('LLL:EXT:backend/Resources/Private/Language/locallang_mfa.xlf:unlock.failure'), $languageService->sL($mfaProvider->getTitle())), '', ContextualFeedbackSeverity::ERROR);
} else {
$this->addFlashMessage(sprintf($languageService->sL('LLL:EXT:backend/Resources/Private/Language/locallang_mfa.xlf:unlock.success'), $languageService->sL($mfaProvider->getTitle())), '', ContextualFeedbackSeverity::OK);
}
return new RedirectResponse($this->getActionUri('overview'));
}
/**
* Render form to edit a provider by using provider specific content
*/
protected function editAction(ServerRequestInterface $request, MfaProviderManifestInterface $mfaProvider): ResponseInterface
{
$propertyManager = MfaProviderPropertyManager::create($mfaProvider, $this->getBackendUser());
if ($mfaProvider->isLocked($propertyManager)) {
// Do not show edit view for locked providers
$this->addFlashMessage($this->getLanguageService()->sL('LLL:EXT:backend/Resources/Private/Language/locallang_mfa.xlf:providerIsLocked'), '', ContextualFeedbackSeverity::ERROR);
return new RedirectResponse($this->getActionUri('overview'));
}
$this->addFormButtons();
$providerResponse = $mfaProvider->handleRequest($request, $propertyManager, MfaViewType::EDIT);
$this->view->assignMultiple([
'provider' => $mfaProvider,
'providerContent' => $providerResponse->getBody(),
'isDefaultProvider' => $this->isDefaultProvider($mfaProvider),
]);
return $this->view->renderResponse('Mfa/Edit');
}
/**
* Handle save request, receiving from the edit view by
* forwarding the request to the appropriate provider.
*/
protected function saveAction(ServerRequestInterface $request, MfaProviderManifestInterface $mfaProvider): ResponseInterface
{
$propertyManager = MfaProviderPropertyManager::create($mfaProvider, $this->getBackendUser());
$languageService = $this->getLanguageService();
if (!$mfaProvider->update($request, $propertyManager)) {
$this->addFlashMessage(sprintf($languageService->sL('LLL:EXT:backend/Resources/Private/Language/locallang_mfa.xlf:save.failure'), $languageService->sL($mfaProvider->getTitle())), '', ContextualFeedbackSeverity::ERROR);
} else {
if ($request->getParsedBody()['defaultProvider'] ?? false) {
$this->setDefaultProvider($mfaProvider);
} elseif ($this->isDefaultProvider($mfaProvider)) {
$this->removeDefaultProvider();
}
$this->addFlashMessage(sprintf($languageService->sL('LLL:EXT:backend/Resources/Private/Language/locallang_mfa.xlf:save.success'), $languageService->sL($mfaProvider->getTitle())), '', ContextualFeedbackSeverity::OK);
}
if (!$mfaProvider->isActive($propertyManager)) {
return new RedirectResponse($this->getActionUri('overview'));
}
return new RedirectResponse($this->getActionUri('edit', ['identifier' => $mfaProvider->getIdentifier()]));
}
/**
* Build a uri for the current controller based on the
* given action, respecting additional parameters.
*/
protected function getActionUri(string $action, array $additionalParameters = []): UriInterface
{
if (!$this->isActionAllowed($action)) {
$action = 'overview';
}
return $this->uriBuilder->buildUriFromRoute('mfa', array_merge(['action' => $action], $additionalParameters));
}
/**
* Check if there are more suitable default providers for the current user
*/
protected function hasSuitableDefaultProviders(array $excludedProviders = []): bool
{
foreach ($this->allowedProviders as $identifier => $provider) {
if (!in_array($identifier, $excludedProviders, true)
&& $provider->isDefaultProviderAllowed()
&& $provider->isActive(MfaProviderPropertyManager::create($provider, $this->getBackendUser()))
) {
return true;
}
}
return false;
}
/**
* Get the default provider
*/
protected function getDefaultProviderIdentifier(): string
{
$defaultProviderIdentifier = (string)($this->getBackendUser()->uc['mfa']['defaultProvider'] ?? '');
// The default provider value is only valid, if the corresponding provider exist and is allowed
if ($this->isValidIdentifier($defaultProviderIdentifier)) {
$defaultProvider = $this->mfaProviderRegistry->getProvider($defaultProviderIdentifier);
$propertyManager = MfaProviderPropertyManager::create($defaultProvider, $this->getBackendUser());
// Also check if the provider is activated for the user
if ($defaultProvider->isActive($propertyManager)) {
return $defaultProviderIdentifier;
}
}
// If the stored provider is not valid, clean up the UC
$this->removeDefaultProvider();
return '';
}
/**
* Get the recommended provider
*/
protected function getRecommendedProviderIdentifier(): string
{
$recommendedProvider = $this->getRecommendedProvider();
if ($recommendedProvider === null) {
return '';
}
$propertyManager = MfaProviderPropertyManager::create($recommendedProvider, $this->getBackendUser());
// If the defined recommended provider is valid, check if it is not yet activated
return !$recommendedProvider->isActive($propertyManager) ? $recommendedProvider->getIdentifier() : '';
}
protected function isDefaultProvider(MfaProviderManifestInterface $mfaProvider): bool
{
return $this->getDefaultProviderIdentifier() === $mfaProvider->getIdentifier();
}
protected function setDefaultProvider(MfaProviderManifestInterface $mfaProvider): void
{
$this->getBackendUser()->uc['mfa']['defaultProvider'] = $mfaProvider->getIdentifier();
$this->getBackendUser()->writeUC();
}
protected function removeDefaultProvider(): void
{
$this->getBackendUser()->uc['mfa']['defaultProvider'] = '';
$this->getBackendUser()->writeUC();
}
protected function addFlashMessage(string $message, string $title = '', ContextualFeedbackSeverity $severity = ContextualFeedbackSeverity::INFO): void
{
$flashMessage = new FlashMessage($message, $title, $severity, true);
$defaultFlashMessageQueue = $this->flashMessageService->getMessageQueueByIdentifier();
$defaultFlashMessageQueue->enqueue($flashMessage);
}
protected function addOverviewButtons(ServerRequestInterface $request): void
{
if (($returnUrl = $this->getReturnUrl($request)) !== '') {
$this->view->addButtonToButtonBar($this->componentFactory->createBackButton($returnUrl));
}
}
protected function addFormButtons(): void
{
$closeButton = $this->componentFactory->createCloseButton((string)$this->uriBuilder->buildUriFromRoute('mfa', ['action' => 'overview']))
->setClasses('t3js-editform-close');
$this->view->addButtonToButtonBar($closeButton);
$this->view->addButtonToButtonBar($this->componentFactory->createSaveButton('mfaConfigurationController')->setName('save'), ButtonBar::BUTTON_POSITION_LEFT, 2);
}
protected function getReturnUrl(ServerRequestInterface $request): string
{
$returnUrl = GeneralUtility::sanitizeLocalUrl(
$request->getQueryParams()['returnUrl'] ?? $request->getParsedBody()['returnUrl'] ?? '',
$request
);
if ($returnUrl === '') {
$returnUrl = (string)$this->uriBuilder->buildUriFromRoute('user_setup');
}
return $returnUrl;
}
}
+244
View File
@@ -0,0 +1,244 @@
<?php
declare(strict_types=1);
/*
* 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 TYPO3\CMS\Backend\Controller;
use Psr\EventDispatcher\EventDispatcherInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Log\LoggerInterface;
use TYPO3\CMS\Backend\Attribute\AsController;
use TYPO3\CMS\Backend\ContextMenu\ItemProviders\ProviderInterface;
use TYPO3\CMS\Backend\Routing\RouteRedirect;
use TYPO3\CMS\Backend\Routing\UriBuilder;
use TYPO3\CMS\Backend\Template\PageRendererBackendSetupTrait;
use TYPO3\CMS\Backend\View\AuthenticationStyleInformation;
use TYPO3\CMS\Backend\View\BackendViewFactory;
use TYPO3\CMS\Core\Authentication\Event\MfaVerificationFailedEvent;
use TYPO3\CMS\Core\Authentication\Mfa\MfaProviderManifestInterface;
use TYPO3\CMS\Core\Authentication\Mfa\MfaProviderPropertyManager;
use TYPO3\CMS\Core\Authentication\Mfa\MfaViewType;
use TYPO3\CMS\Core\Configuration\ExtensionConfiguration;
use TYPO3\CMS\Core\Http\RedirectResponse;
use TYPO3\CMS\Core\Page\PageRenderer;
use TYPO3\CMS\Core\SysLog\Action\Login;
use TYPO3\CMS\Core\SysLog\Error as SystemLogErrorClassification;
use TYPO3\CMS\Core\SysLog\Type as SystemLogType;
/**
* Controller to provide a multi-factor authentication endpoint.
* This is the backend login related view to authenticate against chosen MFA provider.
*
* @internal This class is a specific Backend controller implementation and is not considered part of the Public TYPO3 API.
*/
#[AsController]
class MfaController extends AbstractMfaController
{
use PageRendererBackendSetupTrait;
public function __construct(
protected readonly UriBuilder $uriBuilder,
protected readonly AuthenticationStyleInformation $authenticationStyleInformation,
protected readonly PageRenderer $pageRenderer,
protected readonly ExtensionConfiguration $extensionConfiguration,
protected readonly LoggerInterface $logger,
protected readonly BackendViewFactory $backendViewFactory,
protected readonly EventDispatcherInterface $eventDispatcher,
) {}
/**
* Main entry point, checking prerequisite, initializing and setting
* up the view and finally dispatching to the requested action.
*/
public function handleRequest(ServerRequestInterface $request): ResponseInterface
{
$this->initializeMfaConfiguration();
$action = (string)($request->getQueryParams()['action'] ?? $request->getParsedBody()['action'] ?? 'auth');
switch ($action) {
case 'auth':
case 'verify':
$mfaProvider = $this->getMfaProviderFromRequest($request);
// All actions except "cancel" require a provider to deal with.
// If non is found at this point, throw an exception since this should never happen.
if ($mfaProvider === null) {
throw new \InvalidArgumentException('No active MFA provider was found!', 1611879242);
}
return $this->{$action . 'Action'}($request, $mfaProvider);
case 'cancel':
return $this->cancelAction($request);
default:
throw new \InvalidArgumentException('Action not allowed', 1611879244);
}
}
/**
* Set up the authentication view for the provider by using provider specific content.
*/
protected function authAction(ServerRequestInterface $request, MfaProviderManifestInterface $mfaProvider): ResponseInterface
{
$this->setUpBasicPageRendererForBackend($this->pageRenderer, $this->extensionConfiguration, $request, $this->getLanguageService());
$this->pageRenderer->setTitle('TYPO3 CMS Login: ' . ($GLOBALS['TYPO3_CONF_VARS']['SYS']['sitename'] ?? ''));
$this->pageRenderer->loadJavaScriptModule('bootstrap');
$view = $this->backendViewFactory->create($request);
$propertyManager = MfaProviderPropertyManager::create($mfaProvider, $this->getBackendUser());
$providerResponse = $mfaProvider->handleRequest($request, $propertyManager, MfaViewType::AUTH);
$view->assignMultiple([
'provider' => $mfaProvider,
'alternativeProviders' => $this->getAlternativeProviders($mfaProvider),
'isLocked' => $mfaProvider->isLocked($propertyManager),
'providerContent' => $providerResponse->getBody(),
'footerNote' => $this->authenticationStyleInformation->getFooterNote(),
'formUrl' => $this->uriBuilder->buildUriWithRedirect('auth_mfa', ['action' => 'verify'], RouteRedirect::createFromRequest($request)),
'redirectRoute' => $request->getQueryParams()['redirect'] ?? '',
'redirectParams' => $request->getQueryParams()['redirectParams'] ?? '',
'hasAuthError' => (bool)($request->getQueryParams()['failure'] ?? false),
]);
$this->addCustomAuthenticationFormStyles($request);
$this->pageRenderer->setBodyContent('<body>' . $view->render('Mfa/Auth'));
return $this->pageRenderer->renderResponse($request);
}
/**
* Handle verification request, receiving from the auth view
* by forwarding the request to the appropriate provider.
*/
protected function verifyAction(ServerRequestInterface $request, MfaProviderManifestInterface $mfaProvider): ResponseInterface
{
$backendUser = $this->getBackendUser();
$propertyManager = MfaProviderPropertyManager::create($mfaProvider, $backendUser);
// Check if the provider can process the request and is not temporarily blocked
if (!$mfaProvider->canProcess($request) || $mfaProvider->isLocked($propertyManager)) {
// If this fails, cancel the authentication
return $this->cancelAction($request);
}
// Call the provider to verify the request
if (!$mfaProvider->verify($request, $propertyManager)) {
$this->log(
'Multi-factor authentication failed for user \'###USERNAME###\' with provider \'' . $mfaProvider->getIdentifier() . '\'!',
[],
null,
Login::ATTEMPT,
SystemLogErrorClassification::SECURITY_NOTICE
);
$this->eventDispatcher->dispatch(
new MfaVerificationFailedEvent($request, $propertyManager, $mfaProvider)
);
// If failed, initiate a redirect back to the auth view
return new RedirectResponse($this->uriBuilder->buildUriWithRedirect(
'auth_mfa',
[
'identifier' => $mfaProvider->getIdentifier(),
'failure' => true,
],
RouteRedirect::createFromRequest($request)
));
}
$this->log('Multi-factor authentication successful for user ###USERNAME###');
// If verified, store this information in the session
// and initiate a redirect back to the login view.
$backendUser->setAndSaveSessionData('mfa', true);
$backendUser->handleUserLoggedIn($request);
return new RedirectResponse(
$this->uriBuilder->buildUriWithRedirect('login', [], RouteRedirect::createFromRequest($request))
);
}
/**
* Allow the user to cancel the multi-factor authentication by
* calling logoff on the user object, to destroy the session and
* other already gathered information and finally initiate a
* redirect back to the login.
*/
protected function cancelAction(ServerRequestInterface $request): ResponseInterface
{
$this->log('Multi-factor authentication canceled for user ###USERNAME###');
$this->getBackendUser()->logoff();
return new RedirectResponse($this->uriBuilder->buildUriWithRedirect('login', [], RouteRedirect::createFromRequest($request)));
}
/**
* Fetch alternative (activated and allowed) providers for the user to chose from
*
* @return ProviderInterface[]
*/
protected function getAlternativeProviders(MfaProviderManifestInterface $mfaProvider): array
{
return array_filter($this->allowedProviders, function (MfaProviderManifestInterface $provider) use ($mfaProvider): bool {
return $provider !== $mfaProvider
&& $provider->isActive(MfaProviderPropertyManager::create($provider, $this->getBackendUser()));
});
}
/**
* Log debug information for MFA events
*/
protected function log(
string $message,
array $additionalData = [],
?MfaProviderManifestInterface $mfaProvider = null,
int $action = Login::LOGIN,
int $error = SystemLogErrorClassification::MESSAGE
): void {
$user = $this->getBackendUser();
$username = $user->getUserName();
$context = [
'user' => [
'uid' => $user->getUserId(),
'username' => $username,
],
];
if ($mfaProvider !== null) {
$context['provider'] = $mfaProvider->getIdentifier();
$context['isProviderLocked'] = $mfaProvider->isLocked(
MfaProviderPropertyManager::create($mfaProvider, $user)
);
}
$message = str_replace('###USERNAME###', $username, $message);
$data = array_replace_recursive($context, $additionalData);
$this->logger->debug($message, $data);
if ($user->writeStdLog) {
// Write to sys_log if enabled
$user->writelog(SystemLogType::LOGIN, $action, $error, null, $message, $data);
}
}
protected function getMfaProviderFromRequest(ServerRequestInterface $request): ?MfaProviderManifestInterface
{
$identifier = (string)($request->getQueryParams()['identifier'] ?? $request->getParsedBody()['identifier'] ?? '');
// Check if given identifier is valid
if ($this->isValidIdentifier($identifier)) {
$provider = $this->mfaProviderRegistry->getProvider($identifier);
// Only add provider if it was activated by the current user
if ($provider->isActive(MfaProviderPropertyManager::create($provider, $this->getBackendUser()))) {
return $provider;
}
}
return null;
}
protected function addCustomAuthenticationFormStyles(ServerRequestInterface $request): void
{
if (($backgroundImageStyles = $this->authenticationStyleInformation->getBackgroundImageStyles($request)) !== '') {
$this->pageRenderer->addCssInlineBlock('loginBackgroundImage', $backgroundImageStyles, null, false, true);
}
if (($highlightColorStyles = $this->authenticationStyleInformation->getHighlightColorStyles()) !== '') {
$this->pageRenderer->addCssInlineBlock('loginHighlightColor', $highlightColorStyles, null, false, true);
}
}
}
+275
View File
@@ -0,0 +1,275 @@
<?php
declare(strict_types=1);
/*
* 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 TYPO3\CMS\Backend\Controller;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Log\LoggerInterface;
use TYPO3\CMS\Backend\Attribute\AsController;
use TYPO3\CMS\Backend\Routing\RouteRedirect;
use TYPO3\CMS\Backend\Routing\UriBuilder;
use TYPO3\CMS\Backend\Template\PageRendererBackendSetupTrait;
use TYPO3\CMS\Backend\View\AuthenticationStyleInformation;
use TYPO3\CMS\Backend\View\BackendViewFactory;
use TYPO3\CMS\Core\Authentication\Mfa\MfaProviderManifestInterface;
use TYPO3\CMS\Core\Authentication\Mfa\MfaProviderPropertyManager;
use TYPO3\CMS\Core\Authentication\Mfa\MfaViewType;
use TYPO3\CMS\Core\Configuration\ExtensionConfiguration;
use TYPO3\CMS\Core\Http\HtmlResponse;
use TYPO3\CMS\Core\Http\RedirectResponse;
use TYPO3\CMS\Core\Messaging\FlashMessage;
use TYPO3\CMS\Core\Messaging\FlashMessageService;
use TYPO3\CMS\Core\Page\PageRenderer;
use TYPO3\CMS\Core\Type\ContextualFeedbackSeverity;
use TYPO3\CMS\Core\View\ViewInterface;
/**
* Controller to provide the standalone setup endpoint for multi-factor authentication.
* This is used when MFA is enforced and a backend user logs in the first time to then set up MFA.
*
* @internal This class is a specific Backend controller implementation and is not considered part of the Public TYPO3 API.
*/
#[AsController]
class MfaSetupController extends AbstractMfaController
{
use PageRendererBackendSetupTrait;
protected const ACTION_METHOD_MAP = [
'setup' => 'GET',
'activate' => 'POST',
'cancel' => 'GET',
];
public function __construct(
protected readonly UriBuilder $uriBuilder,
protected readonly AuthenticationStyleInformation $authenticationStyleInformation,
protected readonly PageRenderer $pageRenderer,
protected readonly ExtensionConfiguration $extensionConfiguration,
protected readonly LoggerInterface $logger,
protected readonly BackendViewFactory $backendViewFactory,
protected readonly FlashMessageService $flashMessageService,
) {}
public function handleRequest(ServerRequestInterface $request): ResponseInterface
{
$this->initializeMfaConfiguration();
$action = (string)($request->getQueryParams()['action'] ?? 'setup');
$backendUser = $this->getBackendUser();
if (($backendUser->getSessionData('mfa') ?? false)
|| $backendUser->getOriginalUserIdWhenInSwitchUserMode() !== null
|| !$backendUser->isMfaSetupRequired()
|| $this->mfaProviderRegistry->hasActiveProviders($backendUser)
) {
// Since the current user either did already pass MFA, is in "switch-user" mode,
// is not required to set up MFA or has already activated a provider, throw an
// exception to prevent the endpoint from being called unintentionally by custom code.
throw new \InvalidArgumentException('MFA setup is not necessary. Do not call this endpoint on your own.', 1632154036);
}
$actionMethod = self::ACTION_METHOD_MAP[$action] ?? null;
if ($actionMethod !== null && $request->getMethod() === $actionMethod) {
return $this->{$action . 'Action'}($request);
}
return new HtmlResponse('', 404);
}
/**
* Render form to setup a provider by using provider specific content. Fall
* back to provider selection view, in case no valid provider was yet selected.
*/
protected function setupAction(ServerRequestInterface $request): ResponseInterface
{
$identifier = (string)($request->getQueryParams()['identifier'] ?? '');
if ($identifier === '' || !$this->isValidIdentifier($identifier)) {
return $this->renderSelectionView($request);
}
$mfaProvider = $this->mfaProviderRegistry->getProvider($identifier);
$this->log('Required MFA setup initiated', $mfaProvider);
return $this->renderSetupView($request, $mfaProvider);
}
/**
* Handle activate request, receiving from the setup view
* by forwarding the request to the appropriate provider.
*/
protected function activateAction(ServerRequestInterface $request): ResponseInterface
{
$identifier = (string)($request->getParsedBody()['identifier'] ?? '');
if ($identifier === '' || !$this->isValidIdentifier($identifier)) {
// Return to selection view in case no valid identifier is given
return new RedirectResponse($this->uriBuilder->buildUriWithRedirect('setup_mfa', [], RouteRedirect::createFromRequest($request)));
}
$mfaProvider = $this->mfaProviderRegistry->getProvider($identifier);
$backendUser = $this->getBackendUser();
$propertyManager = MfaProviderPropertyManager::create($mfaProvider, $backendUser);
// Check whether activation operation was successful and the provider is now active.
if (!$mfaProvider->activate($request, $propertyManager) || !$mfaProvider->isActive($propertyManager)) {
$this->log('Required MFA setup failed', $mfaProvider);
return new RedirectResponse(
$this->uriBuilder->buildUriWithRedirect(
'setup_mfa',
[
'identifier' => $mfaProvider->getIdentifier(),
'hasErrors' => true,
],
RouteRedirect::createFromRequest($request)
)
);
}
$this->log('Required MFA setup successful', $mfaProvider);
// Set the activated provider as the default provider, store the "mfa" key in the session data,
// add a flash message to the session and finally initiate a redirect to the login, on which
// possible redirect parameters are evaluated again.
$backendUser->uc['mfa']['defaultProvider'] = $mfaProvider->getIdentifier();
$backendUser->writeUC();
$backendUser->setAndSaveSessionData('mfa', true);
$this->addSuccessMessage($mfaProvider->getTitle());
return new RedirectResponse($this->uriBuilder->buildUriWithRedirect('login', [], RouteRedirect::createFromRequest($request)));
}
/**
* Allow the user to cancel the multi-factor authentication setup process
* by calling logoff on the user object, to destroy the session and other
* already gathered information and finally initiate a redirect back to the login.
*/
protected function cancelAction(ServerRequestInterface $request): ResponseInterface
{
$this->log('Required MFA setup canceled');
$this->getBackendUser()->logoff();
return new RedirectResponse($this->uriBuilder->buildUriWithRedirect('login', [], RouteRedirect::createFromRequest($request)));
}
/**
* Allow the user - required to set up MFA - to select between all available providers
*/
protected function renderSelectionView(ServerRequestInterface $request): ResponseInterface
{
$this->setUpBasicPageRendererForBackend($this->pageRenderer, $this->extensionConfiguration, $request, $this->getLanguageService());
$this->pageRenderer->setTitle('TYPO3 CMS Login: ' . ($GLOBALS['TYPO3_CONF_VARS']['SYS']['sitename'] ?? ''));
$this->pageRenderer->loadJavaScriptModule('bootstrap');
$recommendedProvider = $this->getRecommendedProvider();
$providers = array_filter($this->allowedProviders, static function (MfaProviderManifestInterface $provider) use ($recommendedProvider): bool {
// Remove the recommended provider and providers, which can not be used as default, e.g. recovery codes
return $provider->isDefaultProviderAllowed()
&& ($recommendedProvider === null || $provider->getIdentifier() !== $recommendedProvider->getIdentifier());
});
$view = $this->initializeView($request);
$view->assignMultiple([
'recommendedProvider' => $recommendedProvider,
'providers' => $providers,
]);
$this->pageRenderer->setBodyContent('<body>' . $view->render('Mfa/Standalone/Selection'));
return $this->pageRenderer->renderResponse($request);
}
/**
* Render form to setup a provider by using provider specific content
*/
protected function renderSetupView(
ServerRequestInterface $request,
MfaProviderManifestInterface $mfaProvider
): ResponseInterface {
$this->setUpBasicPageRendererForBackend($this->pageRenderer, $this->extensionConfiguration, $request, $this->getLanguageService());
$this->pageRenderer->setTitle('TYPO3 CMS Login: ' . ($GLOBALS['TYPO3_CONF_VARS']['SYS']['sitename'] ?? ''));
$this->pageRenderer->loadJavaScriptModule('bootstrap');
$propertyManager = MfaProviderPropertyManager::create($mfaProvider, $this->getBackendUser());
$providerResponse = $mfaProvider->handleRequest($request, $propertyManager, MfaViewType::SETUP);
$view = $this->initializeView($request);
$view->assignMultiple([
'provider' => $mfaProvider,
'providerContent' => $providerResponse->getBody(),
'hasErrors' => (bool)($request->getQueryParams()['hasErrors'] ?? false),
]);
$this->pageRenderer->setBodyContent('<body>' . $view->render('Mfa/Standalone/Setup'));
return $this->pageRenderer->renderResponse($request);
}
/**
* Initialize the standalone view by setting the paths and assigning view variables
*/
protected function initializeView(ServerRequestInterface $request): ViewInterface
{
$view = $this->backendViewFactory->create($request);
$view->assignMultiple([
'redirect' => $request->getQueryParams()['redirect'] ?? '',
'redirectParams' => $request->getQueryParams()['redirectParams'] ?? '',
'siteName' => $GLOBALS['TYPO3_CONF_VARS']['SYS']['sitename'],
'footerNote' => $this->authenticationStyleInformation->getFooterNote(),
]);
$this->addCustomAuthenticationFormStyles($request);
return $view;
}
protected function addCustomAuthenticationFormStyles(ServerRequestInterface $request): void
{
if (($backgroundImageStyles = $this->authenticationStyleInformation->getBackgroundImageStyles($request)) !== '') {
$this->pageRenderer->addCssInlineBlock('loginBackgroundImage', $backgroundImageStyles, null, false, true);
}
if (($highlightColorStyles = $this->authenticationStyleInformation->getHighlightColorStyles()) !== '') {
$this->pageRenderer->addCssInlineBlock('loginHighlightColor', $highlightColorStyles, null, false, true);
}
}
/**
* Extend base identifier check to further evaluate whether
* the provider is allowed to be a default provider.
*/
protected function isValidIdentifier(string $identifier): bool
{
return parent::isValidIdentifier($identifier)
&& $this->mfaProviderRegistry->getProvider($identifier)->isDefaultProviderAllowed();
}
/**
* Add a flash message to inform the user about the successful activation of MFA and
* store this in the session, so it will be shown in the backend after the redirect.
*/
protected function addSuccessMessage(string $mfaProviderTitle): void
{
$lang = $this->getLanguageService();
$this->flashMessageService->getMessageQueueByIdentifier()->enqueue(
new FlashMessage(
sprintf($lang->sL('LLL:EXT:backend/Resources/Private/Language/locallang_mfa.xlf:standalone.setup.success.message'), $lang->sL($mfaProviderTitle)),
$lang->sL('LLL:EXT:backend/Resources/Private/Language/locallang_mfa.xlf:standalone.setup.success.title'),
ContextualFeedbackSeverity::OK,
true
)
);
}
/**
* Log debug information for MFA setup events
*/
protected function log(string $message, ?MfaProviderManifestInterface $mfaProvider = null): void
{
$user = $this->getBackendUser();
$context = [
'user' => [
'uid' => $user->getUserId(),
'username' => $user->getUserName(),
],
];
if ($mfaProvider !== null) {
$context['provider'] = $mfaProvider->getIdentifier();
}
$this->logger->debug($message, $context);
}
}
+513
View File
@@ -0,0 +1,513 @@
<?php
declare(strict_types=1);
/*
* 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 TYPO3\CMS\Backend\Controller;
use Psr\EventDispatcher\EventDispatcherInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Backend\Attribute\AsController;
use TYPO3\CMS\Backend\Breadcrumb\BreadcrumbContext;
use TYPO3\CMS\Backend\Controller\Event\ModifyNewRecordCreationLinksEvent;
use TYPO3\CMS\Backend\Routing\PreviewUriBuilder;
use TYPO3\CMS\Backend\Routing\UriBuilder;
use TYPO3\CMS\Backend\Template\Components\ButtonBar;
use TYPO3\CMS\Backend\Template\Components\ComponentFactory;
use TYPO3\CMS\Backend\Template\ModuleTemplateFactory;
use TYPO3\CMS\Backend\Utility\BackendUtility;
use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\DataHandling\PageDoktypeRegistry;
use TYPO3\CMS\Core\Domain\RecordFactory;
use TYPO3\CMS\Core\Http\RedirectResponse;
use TYPO3\CMS\Core\Imaging\IconFactory;
use TYPO3\CMS\Core\Imaging\IconSize;
use TYPO3\CMS\Core\Localization\LanguageService;
use TYPO3\CMS\Core\Package\PackageManager;
use TYPO3\CMS\Core\Page\PageRenderer;
use TYPO3\CMS\Core\Schema\Capability\TcaSchemaCapability;
use TYPO3\CMS\Core\Schema\SchemaLabelResolver;
use TYPO3\CMS\Core\Schema\TcaSchema;
use TYPO3\CMS\Core\Schema\TcaSchemaFactory;
use TYPO3\CMS\Core\SystemResource\Publishing\SystemResourcePublisherInterface;
use TYPO3\CMS\Core\SystemResource\SystemResourceFactory;
use TYPO3\CMS\Core\Type\Bitmask\Permission;
use TYPO3\CMS\Core\Utility\ExtensionManagementUtility;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Renders the 'Create new record' view of the 'db_new' route, reachable from the records module,
* listing the record types a user may create on a given page.
*
* @internal This class is a specific Backend controller implementation and is not considered part of the Public TYPO3 API.
*/
#[AsController]
final readonly class NewRecordController
{
public function __construct(
private ComponentFactory $componentFactory,
private ConnectionPool $connectionPool,
private IconFactory $iconFactory,
private PageDoktypeRegistry $pageDoktypeRegistry,
private PageRenderer $pageRenderer,
private PackageManager $packageManager,
private UriBuilder $uriBuilder,
private RecordFactory $recordFactory,
private ModuleTemplateFactory $moduleTemplateFactory,
private TcaSchemaFactory $tcaSchemaFactory,
private EventDispatcherInterface $eventDispatcher,
private SystemResourceFactory $resourceFactory,
private SystemResourcePublisherInterface $resourcePublisher,
private SchemaLabelResolver $schemaLabelResolver,
) {}
/**
* Collects the record types creatable on the requested page and renders the selection view,
* redirecting straight to the edit form when only a single creation target is available.
*/
public function mainAction(ServerRequestInterface $request): ResponseInterface
{
$view = $this->moduleTemplateFactory->create($request);
$pageinfo = [];
$pidInfo = [];
$newPagesInto = false;
$newContentInto = false;
$newPagesAfter = false;
$tRows = [];
$beUser = $this->getBackendUserAuthentication();
// Page-selection permission clause (reading)
$permsClause = $beUser->getPagePermsClause(Permission::PAGE_SHOW);
// This will hide records from display - it has nothing to do with user rights!!
$pidList = (string)($beUser->getTSConfig()['options.']['hideRecords.']['pages'] ?? '');
if (!empty($pidList)) {
$queryBuilder = $this->connectionPool->getQueryBuilderForTable('pages');
$permsClause .= ' AND ' . $queryBuilder->expr()->notIn(
'pages.uid',
GeneralUtility::intExplode(',', $pidList)
);
}
// Setting GPvars:
$parsedBody = $request->getParsedBody();
$queryParams = $request->getQueryParams();
// The page id to operate from
$pageUid = (int)($parsedBody['id'] ?? $queryParams['id'] ?? 0);
$returnUrl = GeneralUtility::sanitizeLocalUrl($parsedBody['returnUrl'] ?? $queryParams['returnUrl'] ?? '', $request);
// Setting up the context sensitive menu:
$this->pageRenderer->loadJavaScriptModule('@typo3/backend/context-menu.js');
$this->pageRenderer->loadJavaScriptModule('@typo3/backend/new-content-element-wizard-button.js');
$this->pageRenderer->loadJavaScriptModule('@typo3/backend/page-wizard/new-page-wizard-button.js');
// If a positive id is supplied, ask for the page record with permission information contained:
if ($pageUid > 0) {
$pageinfo = BackendUtility::readPageAccess($pageUid, $permsClause) ?: [];
}
// If a page-record was returned, the user had read-access to the page.
if ($pageinfo['uid'] ?? false) {
// Get record of parent page
$pidInfo = BackendUtility::getRecord('pages', ($pageinfo['pid'] ?? 0)) ?? [];
// Checking the permissions for the user with regard to the parent page: Can he create new pages, new
// content record, new page after?
if ($beUser->doesUserHaveAccess($pageinfo, Permission::PAGE_NEW)) {
$newPagesInto = true;
}
if ($beUser->doesUserHaveAccess($pageinfo, Permission::CONTENT_EDIT)) {
$newContentInto = true;
}
if (($beUser->isAdmin() || !empty($pidInfo)) && $beUser->doesUserHaveAccess($pidInfo, Permission::PAGE_NEW)) {
$newPagesAfter = true;
}
$breadcrumbContext = new BreadcrumbContext(
$this->recordFactory->createResolvedRecordFromDatabaseRow('pages', $pageinfo),
[]
);
$view->getDocHeaderComponent()->setBreadcrumbContext($breadcrumbContext);
} elseif ($beUser->isAdmin()) {
// Admins can do it all
$newPagesInto = true;
$newContentInto = true;
$newPagesAfter = false;
} else {
// People with no permission can do nothing
$newPagesInto = false;
$newContentInto = false;
$newPagesAfter = false;
}
$title = $GLOBALS['TYPO3_CONF_VARS']['SYS']['sitename'];
if ($pageinfo['uid'] ?? false) {
$title = strip_tags(BackendUtility::getRecordTitle('pages', $pageinfo));
}
$view->setTitle($title);
// Acquiring TSconfig for this module/current page:
$web_list_modTSconfig = BackendUtility::getPagesTSconfig($pageinfo['uid'] ?? 0)['mod.']['web_list.'] ?? [];
$allowedNewTables = GeneralUtility::trimExplode(',', $web_list_modTSconfig['allowedNewTables'] ?? '', true);
$deniedNewTables = GeneralUtility::trimExplode(',', $web_list_modTSconfig['deniedNewTables'] ?? '', true);
// Acquiring TSconfig for this module/parent page
$web_list_modTSconfig_pid = BackendUtility::getPagesTSconfig($pageinfo['pid'] ?? 0)['mod.']['web_list.'] ?? [];
$allowedNewTables_pid = GeneralUtility::trimExplode(',', $web_list_modTSconfig_pid['allowedNewTables'] ?? '', true);
$deniedNewTables_pid = GeneralUtility::trimExplode(',', $web_list_modTSconfig_pid['deniedNewTables'] ?? '', true);
if (!$this->isRecordCreationAllowedForTable('pages', $allowedNewTables, $deniedNewTables)) {
$newPagesInto = false;
}
if (!$this->isRecordCreationAllowedForTable('pages', $allowedNewTables_pid, $deniedNewTables_pid)) {
$newPagesAfter = false;
}
// If there was a page - or if the user is admin (admins has access to the root) we proceed, otherwise just output the header
if (empty($pageinfo['uid']) && !$this->getBackendUserAuthentication()->isAdmin()) {
return $view->renderResponse('NewRecord/NewRecord');
}
$lang = $this->getLanguageService();
// Get TSconfig for current page
$pageTS = BackendUtility::getPagesTSconfig($pageUid);
// Finish initializing new pages options with TSconfig
// Each new page option may be hidden by TSconfig
$displayNewPagesIntoLink = $newPagesInto && !empty($pageTS['mod.']['wizards.']['newRecord.']['pages.']['show.']['pageInside']);
$displayNewPagesAfterLink = $newPagesAfter && !empty($pageTS['mod.']['wizards.']['newRecord.']['pages.']['show.']['pageAfter']);
$iconFile = [
'backendaccess' => $this->iconFactory->getIcon('status-user-group-backend', IconSize::SMALL),
'content' => $this->iconFactory->getIcon('content-panel', IconSize::SMALL)->render(),
'frontendaccess' => $this->iconFactory->getIcon('status-user-group-frontend', IconSize::SMALL),
'system' => $this->iconFactory->getIcon('apps-pagetree-root', IconSize::SMALL),
];
$groupTitles = [
'backendaccess' => $lang->sL('LLL:EXT:core/Resources/Private/Language/locallang_misc.xlf:recordgroup.backendaccess'),
'content' => $lang->sL('LLL:EXT:core/Resources/Private/Language/locallang_misc.xlf:recordgroup.content'),
'frontendaccess' => $lang->sL('LLL:EXT:core/Resources/Private/Language/locallang_misc.xlf:recordgroup.frontendaccess'),
'system' => $lang->sL('LLL:EXT:core/Resources/Private/Language/locallang_misc.xlf:system_records'),
];
$allowedTables = [];
foreach ($this->tcaSchemaFactory->all() as $table => $schema) {
$isTablesAllowed = match ($table) {
'pages' => $this->isRecordCreationAllowedForTable('pages', $allowedNewTables, $deniedNewTables),
'tt_content' => false, // Skip, as inserting content elements is part of the page module
default => $newContentInto && $this->isRecordCreationAllowedForTable($table, $allowedNewTables, $deniedNewTables) && $this->isTableAllowedOnPage($schema, $pageinfo, $pageUid)
};
if ($isTablesAllowed) {
$allowedTables[] = $table;
}
}
$groupedLinksOnTop = [];
foreach ($allowedTables as $table) {
$schema = $this->tcaSchemaFactory->get($table);
$ctrlTitle = $schema->getTitle();
if ($table === 'pages') {
// New pages INSIDE this pages
$newPageLinks = [];
if ($displayNewPagesIntoLink && $this->isTableAllowedOnPage($schema, $pageinfo, $pageUid)) {
// Create link to new page inside
$newPageLinks['inside'] = [
'icon' => $this->iconFactory->getIconForRecord($table, [], IconSize::SMALL),
'label' => $lang->sL($ctrlTitle) . ' (' . $lang->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:db_new.php.inside') . ')',
'wizardConfiguration' => ['positionData' => ['pageUid' => $pageUid, 'insertPosition' => 'inside']],
];
}
// New pages AFTER this pages
if ($displayNewPagesAfterLink && $this->isTableAllowedOnPage($schema, $pidInfo, $pageUid)) {
$newPageLinks['after'] = [
'icon' => $this->iconFactory->getIconForRecord($table, [], IconSize::SMALL),
'label' => $lang->sL($ctrlTitle) . ' (' . $lang->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:db_new.php.after') . ')',
'wizardConfiguration' => ['positionData' => ['pageUid' => $pageUid, 'insertPosition' => 'after']],
];
}
if (!empty($newPageLinks)) {
$groupedLinksOnTop['pages'] = [
'title' => $lang->sL('LLL:EXT:core/Resources/Private/Language/locallang_misc.xlf:createNewPage'),
'icon' => $this->iconFactory->getIcon('actions-page-new', IconSize::SMALL),
'items' => $newPageLinks,
];
}
} else {
$nameParts = explode('_', $table);
$groupName = $schema->getRawConfiguration()['groupName'] ?? '';
if (!isset($iconFile[$groupName]) || $nameParts[0] === 'tx' || $nameParts[0] === 'tt') {
$groupName = $groupName ?: ($nameParts[1] ?? null);
// Try to extract extension name
if ($groupName) {
$_EXTKEY = '';
$titleIsTranslatableLabel = str_starts_with($ctrlTitle, 'LLL:EXT:');
if ($titleIsTranslatableLabel) {
// In case the title is a locallang reference, we can simply
// extract the extension name from the given extension path.
$_EXTKEY = substr($ctrlTitle, 8);
$_EXTKEY = substr($_EXTKEY, 0, (int)strpos($_EXTKEY, '/'));
} elseif (ExtensionManagementUtility::isLoaded($groupName)) {
// In case $title is not a locallang reference, we check the groupName to
// be a valid extension key. This most probably work since by convention the
// first part after tx_ / tt_ is the extension key.
$_EXTKEY = $groupName;
}
// Fetch the group title from the extension name
if ($_EXTKEY !== '') {
// Try to get the extension title
$package = $this->packageManager->getPackage($_EXTKEY);
$groupTitle = $lang->sL('LLL:EXT:' . $_EXTKEY . '/Resources/Private/Language/locallang_db.xlf:extension.title');
// If no localisation available, read title from the Package MetaData
if (!$groupTitle) {
$groupTitle = $package->getPackageMetaData()->getTitle();
}
$extensionIcon = $package->getResources()->getPackageIcon();
if ($extensionIcon !== null) {
$iconResource = $this->resourceFactory->createPublicResource($extensionIcon);
$iconFile[$groupName] = '<img src="' . htmlspecialchars((string)$this->resourcePublisher->generateUri($iconResource, $request)) . '" width="16" height="16" alt="' . $groupTitle . '" />';
}
if (!empty($groupTitle)) {
$groupTitles[$groupName] = $groupTitle;
} else {
$groupTitles[$groupName] = ucwords($_EXTKEY);
}
}
} else {
// Fall back to "system" in case no $groupName could be found
$groupName = 'system';
}
}
$tRows[$groupName]['title'] = $tRows[$groupName]['title'] ?? $groupTitles[$groupName] ?? $nameParts[1] ?? $ctrlTitle;
$tRows[$groupName]['icon'] = $tRows[$groupName]['icon'] ?? $iconFile[$groupName] ?? $iconFile['system'] ?? '';
if ($schema->supportsSubSchema()
&& !$schema->getSubSchemaTypeInformation()->isPointerToForeignFieldInForeignSchema()
&& $this->hasRecordTypesForDirectCreation($schema)
) {
$tRows[$groupName]['items'][$table]['label'] = $lang->sL($ctrlTitle);
$tRows[$groupName]['items'][$table]['icon'] = $this->iconFactory->getIconForRecord($table, [], IconSize::SMALL);
$tRows[$groupName]['items'][$table]['types'] = $this->getRecordTypesForDirectCreation($request, $schema, $pageUid, $returnUrl);
} else {
$tRows[$groupName]['items'][$table] = [
'url' => $this->renderLink($request, $table, $pageUid, [], $returnUrl),
'icon' => $this->iconFactory->getIconForRecord($table, [], IconSize::SMALL)->render(),
'label' => $lang->sL($ctrlTitle),
];
}
}
}
// User sort
$newRecordSortList = isset($pageTS['mod.']['wizards.']['newRecord.']['order'])
? GeneralUtility::trimExplode(',', $pageTS['mod.']['wizards.']['newRecord.']['order'], true)
: [];
uksort($tRows, static function (string $a, string $b) use ($newRecordSortList, $tRows): int {
if ($newRecordSortList !== []) {
if (in_array($a, $newRecordSortList) && in_array($b, $newRecordSortList)) {
// Both are in the list, return relative to position in array
$sub = array_search($a, $newRecordSortList) - array_search($b, $newRecordSortList);
$ret = ($sub < 0 ? -1 : $sub == 0) ? 0 : 1;
} elseif (in_array($a, $newRecordSortList)) {
// First element is in array, put to top
$ret = -1;
} elseif (in_array($b, $newRecordSortList)) {
// Second element is in array, put first to bottom
$ret = 1;
} else {
// No element is in array, return alphabetic order
$ret = strnatcasecmp($tRows[$a]['title'] ?? '', $tRows[$b]['title'] ?? '');
}
return $ret;
}
// Return alphabetic order
return strnatcasecmp($tRows[$a]['title'] ?? '', $tRows[$b]['title'] ?? '');
});
$tRows = array_merge($groupedLinksOnTop, $tRows);
$tRows = $this->eventDispatcher->dispatch(
new ModifyNewRecordCreationLinksEvent($tRows, $pageTS, $pageUid, $request)
)->groupedCreationLinks;
$recordControls = $tRows;
if (count($recordControls) === 1) {
$items = current($recordControls)['items'] ?? [];
if (count($items) === 1) {
$item = current($items);
// Items for tables with sub-types carry a 'types' sub-array instead of a 'url'
// and must fall through to render the selection wizard.
if (isset($item['url'])) {
return new RedirectResponse($item['url'], 301);
}
}
}
$view->assign('recordTypeGroups', $recordControls);
// Setting up the buttons and markers for docheader (done after permissions are checked)
// Back
if ($returnUrl) {
$view->addButtonToButtonBar($this->componentFactory->createBackButton($returnUrl), ButtonBar::BUTTON_POSITION_LEFT, 10);
}
if ($pageinfo['uid'] ?? false) {
// View
$previewUriBuilder = PreviewUriBuilder::create($pageinfo);
if ($previewUriBuilder->isPreviewable()) {
$view->addButtonToButtonBar(
$this->componentFactory->createViewButton($previewUriBuilder
->withRootLine(BackendUtility::BEgetRootLine($pageinfo['uid']))
->buildDispatcherDataAttributes() ?? []),
ButtonBar::BUTTON_POSITION_LEFT,
30
);
}
}
return $view->renderResponse('NewRecord/NewRecord');
}
/**
* Links the string $code to a create-new form for a record in $table created on page $pid
*
* @param string $table Table name (in which to create new record)
* @param int $pid PID value for the "&edit['.$table.']['.$pid.']=new" command (positive/negative)
* @param array $additionalParams Additional params, such as "defVals" tp be added to the link
* @param string $returnUrl Return URL, falls back to the current request URI when empty
* @return string The link.
*/
private function renderLink(ServerRequestInterface $request, string $table, int $pid, array $additionalParams, string $returnUrl): string
{
$params = [
'edit' => [
$table => [
$pid => 'new',
],
],
'returnUrl' => $returnUrl ?: $request->getAttribute('normalizedParams')->getRequestUri(),
];
if ($additionalParams) {
$params = array_replace_recursive($params, $additionalParams);
}
return (string)$this->uriBuilder->buildUriFromRoute('record_edit', $params);
}
/**
* Returns TRUE if the tablename $checkTable is allowed to be created on the page with record $pid_row
*
* @param TcaSchema $schema Table schema
* @param array $page Potential parent page
* @param int $pageUid Current page id
* @return bool Returns TRUE if the tablename $table is allowed to be created on the $page
*/
private function isTableAllowedOnPage(TcaSchema $schema, array $page, int $pageUid): bool
{
$rootLevelCapability = $schema->getCapability(TcaSchemaCapability::RestrictionRootLevel);
$rootLevelConstraintMatches = ($rootLevelCapability->canExistOnRootLevel() && $pageUid === 0) || ($pageUid && $rootLevelCapability->canExistOnPages());
if (empty($page)) {
return $rootLevelConstraintMatches && $this->getBackendUserAuthentication()->isAdmin();
}
if (!$this->getBackendUserAuthentication()->workspaceCanCreateNewRecord($schema->getName())) {
return false;
}
// Checking doktype
$isAllowed = $this->pageDoktypeRegistry->isRecordTypeAllowedForDoktype($schema->getName(), $page['doktype']);
return $rootLevelConstraintMatches && $isAllowed;
}
/**
* Returns whether the record link should be shown for a table
*
* Returns TRUE if:
* - $allowedNewTables and $deniedNewTables are empty
* - the table is not found in $deniedNewTables and $allowedNewTables is not set or the $table tablename is found in
* $allowedNewTables
*
* If $table tablename is found in $allowedNewTables and $deniedNewTables,
* $deniedNewTables has priority over $allowedNewTables.
*
* @param string $table Table name to test if in allowedTables
* @param array $allowedNewTables Array of new tables that are allowed.
* @param array $deniedNewTables Array of new tables that are not allowed.
* @return bool Returns TRUE if a link for creating new records should be displayed for $table
*/
private function isRecordCreationAllowedForTable(string $table, array $allowedNewTables, array $deniedNewTables): bool
{
if (!$this->getBackendUserAuthentication()->check('tables_modify', $table)) {
return false;
}
$schema = $this->tcaSchemaFactory->get($table);
if ($schema->hasCapability(TcaSchemaCapability::AccessReadOnly)
|| $schema->hasCapability(TcaSchemaCapability::HideInUi)
|| ($schema->hasCapability(TcaSchemaCapability::AccessAdminOnly) && !$this->getBackendUserAuthentication()->isAdmin())
) {
return false;
}
// No deny/allow tables are set:
if (empty($allowedNewTables) && empty($deniedNewTables)) {
return true;
}
return !in_array($table, $deniedNewTables) && (empty($allowedNewTables) || in_array($table, $allowedNewTables));
}
private function hasRecordTypesForDirectCreation(TcaSchema $schema): bool
{
if (count($schema->getSubSchemata()) <= 1) {
return false;
}
foreach ($schema->getSubSchemata() as $subSchema) {
if ((bool)($subSchema->getRawConfiguration()['creationOptions']['enableDirectRecordTypeCreation'] ?? true) === false) {
continue;
}
return true;
}
return false;
}
private function getRecordTypesForDirectCreation(ServerRequestInterface $request, TcaSchema $schema, int $pageUid, string $returnUrl): array
{
$recordTypes = [];
$lang = $this->getLanguageService();
$recordTypeField = $schema->getSubSchemaTypeInformation()->getFieldName();
foreach ($schema->getSubSchemata() as $subSchema) {
$creationOptions = $subSchema->getRawConfiguration()['creationOptions'] ?? [];
if ((bool)($creationOptions['enableDirectRecordTypeCreation'] ?? true) === false) {
continue;
}
$recordTypeName = array_map(trim(...), explode('.', $subSchema->getName(), 2))[1] ?? '';
$recordTypes[$recordTypeName] = [
'url' => $this->renderLink($request, $schema->getName(), $pageUid, [
'defVals' => [
$schema->getName() => [
$recordTypeField => $recordTypeName,
],
],
], $returnUrl),
'icon' => $this->iconFactory->getIconForRecord($schema->getName(), [$recordTypeField => $recordTypeName], IconSize::SMALL),
'label' => $lang->sL($this->schemaLabelResolver->getLabelForFieldValue(
$schema->getName(),
$recordTypeField,
$recordTypeName,
[],
BackendUtility::getPagesTSconfig($pageUid)['TCEFORM.'][$schema->getName() . '.'][$recordTypeField . '.'] ?? [],
)) ?: $lang->sL($subSchema->getTitle())
?: $recordTypeName,
];
}
return $recordTypes;
}
private function getLanguageService(): LanguageService
{
return $GLOBALS['LANG'];
}
private function getBackendUserAuthentication(): BackendUserAuthentication
{
return $GLOBALS['BE_USER'];
}
}
@@ -0,0 +1,179 @@
<?php
declare(strict_types=1);
/*
* 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 TYPO3\CMS\Backend\Controller;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Backend\Attribute\AsController;
use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
use TYPO3\CMS\Core\Http\JsonResponse;
use TYPO3\CMS\Core\Http\RedirectResponse;
use TYPO3\CMS\Core\Localization\LanguageService;
use TYPO3\CMS\Core\Messaging\FlashMessage;
use TYPO3\CMS\Core\Messaging\FlashMessageService;
use TYPO3\CMS\Core\Resource\DefaultUploadFolderResolver;
use TYPO3\CMS\Core\Resource\Exception\OnlineMediaAlreadyExistsException;
use TYPO3\CMS\Core\Resource\File;
use TYPO3\CMS\Core\Resource\OnlineMedia\Helpers\OnlineMediaHelperRegistry;
use TYPO3\CMS\Core\Resource\ResourceFactory;
use TYPO3\CMS\Core\Type\ContextualFeedbackSeverity;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Class handles uploading online media
* @internal This class is a specific Backend controller implementation and is not considered part of the Public TYPO3 API.
*/
#[AsController]
readonly class OnlineMediaController
{
public function __construct(
protected ResourceFactory $resourceFactory,
protected DefaultUploadFolderResolver $uploadFolderResolver,
protected OnlineMediaHelperRegistry $onlineMediaHelperRegistry,
protected FlashMessageService $flashMessageService
) {}
/**
* AJAX endpoint for storing the URL as a sys_file record
*/
public function createAction(ServerRequestInterface $request): ResponseInterface
{
$url = $request->getParsedBody()['url'];
$targetFolderIdentifier = $request->getParsedBody()['targetFolder'];
$allowedExtensions = GeneralUtility::trimExplode(',', $request->getParsedBody()['allowed'] ?: '');
if (!empty($url)) {
$data = [];
try {
$file = $this->addMediaFromUrl($url, $targetFolderIdentifier, $allowedExtensions);
} catch (OnlineMediaAlreadyExistsException $e) {
// Ignore this exception since the endpoint is called e.g. in inline context, where the
// folder is not relevant and the same asset can be attached to a record multiple times.
$file = $e->getOnlineMedia();
}
if ($file !== null) {
$data['file'] = $file->getUid();
} else {
$data['error'] = $this->getLanguageService()->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:online_media.error.invalid_url');
}
return new JsonResponse($data);
}
return new JsonResponse();
}
/**
* Process add media request, and redirects to the previous page
*
* @throws \RuntimeException
*/
public function mainAction(ServerRequestInterface $request): ResponseInterface
{
$files = $request->getParsedBody()['data'];
$redirect = $request->getParsedBody()['redirect'];
$newMedia = [];
if (isset($files['newMedia'])) {
$newMedia = (array)$files['newMedia'];
}
foreach ($newMedia as $media) {
if (!empty($media['url']) && !empty($media['target'])) {
$allowed = !empty($media['allowed']) ? GeneralUtility::trimExplode(',', $media['allowed']) : [];
try {
$file = $this->addMediaFromUrl($media['url'], $media['target'], $allowed);
if ($file !== null) {
$flashMessage = new FlashMessage(
$file->getName(),
$this->getLanguageService()->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:online_media.new_media.added'),
ContextualFeedbackSeverity::OK,
true
);
} else {
$flashMessage = new FlashMessage(
$this->getLanguageService()->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:online_media.error.invalid_url'),
$this->getLanguageService()->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:online_media.error.new_media.failed'),
ContextualFeedbackSeverity::ERROR,
true
);
}
} catch (OnlineMediaAlreadyExistsException $e) {
$flashMessage = new FlashMessage(
sprintf(
$this->getLanguageService()->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:online_media.error.already_exists'),
$e->getOnlineMedia()->getName()
),
$this->getLanguageService()->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:online_media.error.new_media.failed'),
ContextualFeedbackSeverity::WARNING,
true
);
}
$this->addFlashMessage($flashMessage);
if (empty($redirect) && $media['redirect']) {
$redirect = $media['redirect'];
}
}
}
$redirect = GeneralUtility::sanitizeLocalUrl($redirect, $request);
if ($redirect) {
return new RedirectResponse($redirect, 303);
}
throw new \RuntimeException('No redirect after uploading a media found, probably a mis-use of the template not sending the proper Return URL.', 1511945040);
}
/**
* @param string $url
* @param string $targetFolderIdentifier
* @param string[] $allowedExtensions
* @return File|null
*/
protected function addMediaFromUrl($url, $targetFolderIdentifier, array $allowedExtensions = [])
{
$targetFolder = null;
if ($targetFolderIdentifier) {
try {
$targetFolder = $this->resourceFactory->getFolderObjectFromCombinedIdentifier($targetFolderIdentifier);
} catch (\Exception $e) {
$targetFolder = null;
}
}
if ($targetFolder === null) {
$targetFolder = $this->uploadFolderResolver->resolve($this->getBackendUser());
}
return $this->onlineMediaHelperRegistry->transformUrlToFile($url, $targetFolder, $allowedExtensions);
}
/**
* Add flash message to message queue
*/
protected function addFlashMessage(FlashMessage $flashMessage): void
{
$defaultFlashMessageQueue = $this->flashMessageService->getMessageQueueByIdentifier();
$defaultFlashMessageQueue->enqueue($flashMessage);
}
protected function getBackendUser(): BackendUserAuthentication
{
return $GLOBALS['BE_USER'];
}
protected function getLanguageService(): LanguageService
{
return $GLOBALS['LANG'];
}
}
@@ -0,0 +1,218 @@
<?php
declare(strict_types=1);
/*
* 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 TYPO3\CMS\Backend\Controller\Page;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Backend\Attribute\AsController;
use TYPO3\CMS\Backend\Routing\UriBuilder;
use TYPO3\CMS\Backend\Template\PageRendererBackendSetupTrait;
use TYPO3\CMS\Backend\Utility\BackendUtility;
use TYPO3\CMS\Backend\View\BackendViewFactory;
use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
use TYPO3\CMS\Core\Configuration\ExtensionConfiguration;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Database\Query\QueryHelper;
use TYPO3\CMS\Core\Database\Query\Restriction\DeletedRestriction;
use TYPO3\CMS\Core\Database\Query\Restriction\WorkspaceRestriction;
use TYPO3\CMS\Core\Http\HtmlResponse;
use TYPO3\CMS\Core\Localization\LanguageServiceFactory;
use TYPO3\CMS\Core\Page\JavaScriptModuleInstruction;
use TYPO3\CMS\Core\Page\PageRenderer;
use TYPO3\CMS\Core\Type\Bitmask\Permission;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* The "move page" wizard. Reachable via records module "Move page" on page records.
*
* @internal This class is a specific Backend controller implementation and is not considered part of the Public TYPO3 API.
*/
#[AsController]
final readonly class MovePageController
{
use PageRendererBackendSetupTrait;
public function __construct(
private PageRenderer $pageRenderer,
private BackendViewFactory $backendViewFactory,
private UriBuilder $uriBuilder,
private LanguageServiceFactory $languageServiceFactory,
private ExtensionConfiguration $extensionConfiguration,
private ConnectionPool $connectionPool,
) {}
public function mainAction(ServerRequestInterface $request): ResponseInterface
{
$this->setUpBasicPageRendererForBackend(
$this->pageRenderer,
$this->extensionConfiguration,
$request,
$this->languageServiceFactory->createFromUserPreferences($this->getBackendUser())
);
$view = $this->backendViewFactory->create($request);
$queryParams = $request->getQueryParams();
$contentOnly = $queryParams['contentOnly'] ?? false;
$this->pageRenderer->loadJavaScriptModule('@typo3/backend/tree/page-browser.js');
$this->pageRenderer->getJavaScriptRenderer()->addJavaScriptModuleInstruction(
JavaScriptModuleInstruction::create('@typo3/backend/wizard/move-page.js', 'MovePage')->instance()
);
$this->pageRenderer->addInlineLanguageLabelFile('EXT:core/Resources/Private/Language/locallang_core.xlf');
$this->pageRenderer->addInlineLanguageLabelFile('EXT:core/Resources/Private/Language/locallang_misc.xlf');
$this->pageRenderer->addInlineLanguageLabelFile('EXT:backend/Resources/Private/Language/Wizards/move_page.xlf');
$targetPid = (int)($queryParams['expandPage'] ?? 0);
$pageIdToMove = (int)($queryParams['uid'] ?? 0);
$makeCopy = (bool)($queryParams['makeCopy'] ?? 0);
if ($targetPid) {
$view->assignMultiple($this->getContentVariables($pageIdToMove, $targetPid));
}
$view->assignMultiple([
'activePage' => $targetPid,
'contentOnly' => $contentOnly,
// Make-copy checkbox (clicking this will reload the page with the GET var makeCopy set differently):
'makeCopyChecked' => $makeCopy,
'makeCopyUrl' => $this->uriBuilder->buildUriFromRoute(
'move_page',
[
'uid' => $pageIdToMove,
'makeCopy' => !$makeCopy,
]
),
]);
$content = $view->render('Page/MovePage');
if ($contentOnly) {
return new HtmlResponse($content);
}
$this->pageRenderer->setBodyContent('<body>' . $content);
return new HtmlResponse($this->pageRenderer->render($request));
}
private function getContentVariables(int $pageIdToMove, int $targetPid): array
{
$elementRow = BackendUtility::getRecordWSOL('pages', $pageIdToMove);
$targetRow = BackendUtility::getRecordWSOL('pages', $targetPid);
if (!$this->getBackendUser()->doesUserHaveAccess($targetRow, Permission::PAGE_EDIT)) {
return [];
}
return [
'targetHasSubpages' => $this->pageHasSubpages($targetPid),
'element' => [
'record' => $elementRow,
'recordTooltip' => BackendUtility::getRecordIconAltText($elementRow, 'pages', false),
'recordTitle' => BackendUtility::getRecordTitle('pages', $elementRow),
'recordPath' => BackendUtility::getRecordPath($pageIdToMove, $this->getBackendUser()->getPagePermsClause(Permission::PAGE_SHOW), 0),
],
'target' => [
'record' => $targetRow,
'recordTooltip' => BackendUtility::getRecordIconAltText($targetRow, 'pages', false),
'recordTitle' => BackendUtility::getRecordTitle('pages', $targetRow),
'recordPath' => BackendUtility::getRecordPath($targetPid, $this->getBackendUser()->getPagePermsClause(Permission::PAGE_SHOW), 0),
],
'positions' => [
'above' => $this->getTargetForAboveInsert($targetRow),
'inside' => $targetRow['uid'],
'below' => $targetRow['uid'] * -1,
],
'hasEditPermissions' => $this->getBackendUser()->doesUserHaveAccess($elementRow, Permission::PAGE_EDIT),
'isDifferentPage' => $pageIdToMove !== $targetRow['uid'],
];
}
private function getTargetForAboveInsert(array $targetRow): int
{
$targetPageId = (int)$targetRow['uid'];
$subpages = $this->getSubpagesForPageId($targetRow['pid']);
if (in_array($targetPageId, $subpages, true)) {
// Set pointer in array to $targetPid
while (current($subpages) !== $targetPageId) {
if (next($subpages) === false) {
// We reached the end of the array and couldn't find the target pid (how?). Fall back to pid
return (int)$targetRow['pid'];
}
}
$previousItem = prev($subpages);
if ($previousItem !== false) {
return $previousItem * -1;
}
}
return (int)$targetRow['pid'];
}
private function getSubpagesForPageId(int $pageId): array
{
$queryBuilder = $this->connectionPool->getQueryBuilderForTable('pages');
$queryBuilder
->getRestrictions()
->removeAll()
->add(GeneralUtility::makeInstance(DeletedRestriction::class))
->add(GeneralUtility::makeInstance(WorkspaceRestriction::class, $this->getBackendUser()->workspace));
return $queryBuilder
->select('uid')
->from('pages')
->where(
$queryBuilder->expr()->eq('pid', $queryBuilder->createNamedParameter($pageId)),
$queryBuilder->expr()->or(
$queryBuilder->expr()->eq('language_tag', $queryBuilder->createNamedParameter(\Local\Multilanguage\Service\DefaultLanguageTagService::getTag())),
$queryBuilder->expr()->eq('language_tag', $queryBuilder->createNamedParameter('')),
),
QueryHelper::stripLogicalOperatorPrefix(
$this->getBackendUser()->getPagePermsClause(Permission::PAGE_SHOW)
)
)
->orderBy('sorting')
->executeQuery()
->fetchFirstColumn();
}
private function pageHasSubpages(int $pageId): bool
{
$queryBuilder = $this->connectionPool->getQueryBuilderForTable('pages');
$queryBuilder
->getRestrictions()
->removeAll()
->add(GeneralUtility::makeInstance(DeletedRestriction::class))
->add(GeneralUtility::makeInstance(WorkspaceRestriction::class, $this->getBackendUser()->workspace));
$count = (int)$queryBuilder
->count('uid')
->from('pages')
->where(
$queryBuilder->expr()->eq('pid', $queryBuilder->createNamedParameter($pageId)),
$queryBuilder->expr()->or(
$queryBuilder->expr()->eq('language_tag', $queryBuilder->createNamedParameter(\Local\Multilanguage\Service\DefaultLanguageTagService::getTag())),
$queryBuilder->expr()->eq('language_tag', $queryBuilder->createNamedParameter('')),
),
QueryHelper::stripLogicalOperatorPrefix(
$this->getBackendUser()->getPagePermsClause(Permission::PAGE_SHOW)
)
)
->executeQuery()
->fetchOne();
return $count > 0;
}
private function getBackendUser(): BackendUserAuthentication
{
return $GLOBALS['BE_USER'];
}
}
@@ -0,0 +1,292 @@
<?php
declare(strict_types=1);
/*
* 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 TYPO3\CMS\Backend\Controller\Page;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Backend\Attribute\AsController;
use TYPO3\CMS\Backend\Form\FormDataCompiler;
use TYPO3\CMS\Backend\Form\FormDataGroup\OnTheFly;
use TYPO3\CMS\Backend\Form\FormDataProvider\DatabaseEffectivePid;
use TYPO3\CMS\Backend\Form\FormDataProvider\DatabaseParentPageRow;
use TYPO3\CMS\Backend\Form\FormDataProvider\DatabaseRowInitializeNew;
use TYPO3\CMS\Backend\Form\FormDataProvider\DatabaseUniqueUidNewRow;
use TYPO3\CMS\Backend\Form\FormDataProvider\DatabaseUserPermissionCheck;
use TYPO3\CMS\Backend\Form\FormDataProvider\InitializeProcessedTca;
use TYPO3\CMS\Backend\Form\FormDataProvider\PageTsConfig;
use TYPO3\CMS\Backend\Form\FormDataProvider\TcaSelectItems;
use TYPO3\CMS\Backend\Form\FormDataProvider\UserTsConfig;
use TYPO3\CMS\Backend\Routing\PreviewUriBuilder;
use TYPO3\CMS\Backend\Template\Components\ComponentFactory;
use TYPO3\CMS\Backend\Template\ModuleTemplateFactory;
use TYPO3\CMS\Backend\Utility\BackendUtility;
use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
use TYPO3\CMS\Core\Database\Connection;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Database\Query\Restriction\DeletedRestriction;
use TYPO3\CMS\Core\DataHandling\DataHandler;
use TYPO3\CMS\Core\Localization\LanguageService;
use TYPO3\CMS\Core\Schema\Capability\TcaSchemaCapability;
use TYPO3\CMS\Core\Schema\TcaSchemaFactory;
use TYPO3\CMS\Core\Type\Bitmask\Permission;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Core\Utility\MathUtility;
/**
* "Create multiple pages" controller
*
* @internal This class is a specific Backend controller implementation and is not considered part of the Public TYPO3 API.
*/
#[AsController]
readonly class NewMultiplePagesController
{
public function __construct(
protected ModuleTemplateFactory $moduleTemplateFactory,
protected TcaSchemaFactory $tcaSchemaFactory,
protected ComponentFactory $componentFactory,
protected FormDataCompiler $formDataCompiler,
) {}
/**
* Main function Handling input variables and rendering main view.
*/
public function mainAction(ServerRequestInterface $request): ResponseInterface
{
$view = $this->moduleTemplateFactory->create($request);
$backendUser = $this->getBackendUser();
$pageUid = (int)$request->getQueryParams()['id'];
// Show only if there is a valid page and if this page may be viewed by the user
$pageRecord = BackendUtility::readPageAccess($pageUid, $backendUser->getPagePermsClause(Permission::PAGE_SHOW));
if (!is_array($pageRecord)) {
// User has no permission on parent page, should not happen, just render an empty page
return $view->renderResponse('Dummy/Index');
}
// Doc header handling
$view->getDocHeaderComponent()->setPageBreadcrumb($pageRecord);
$view->addButtonToButtonBar(
$this->componentFactory->createViewButton(
PreviewUriBuilder::create($pageRecord)
->withRootLine(BackendUtility::BEgetRootLine($pageUid))
->buildDispatcherDataAttributes() ?? []
)
);
$calculatedPermissions = new Permission($backendUser->calcPerms($pageRecord));
$canCreateNew = $backendUser->isAdmin() || $calculatedPermissions->createPagePermissionIsGranted();
$view->assignMultiple([
'canCreateNew' => $canCreateNew,
'maxTitleLength' => $backendUser->uc['titleLen'] ?? 20,
'pageUid' => $pageUid,
]);
if ($canCreateNew) {
$newPagesData = (array)($request->getParsedBody()['pages'] ?? []);
if (!empty($newPagesData)) {
$hasNewPagesData = true;
$afterExisting = isset($request->getParsedBody()['createInListEnd']);
$hidePages = isset($request->getParsedBody()['hidePages']);
$hidePagesInMenu = isset($request->getParsedBody()['hidePagesInMenus']);
$pagesCreated = $this->createPages($newPagesData, $pageUid, $afterExisting, $hidePages, $hidePagesInMenu);
$view->assign('pagesCreated', $pagesCreated);
$subPages = $this->getSubPagesOfPage($pageUid);
$visiblePages = [];
foreach ($subPages as $page) {
$calculatedPermissions = new Permission($backendUser->calcPerms($page));
if ($backendUser->isAdmin() || $calculatedPermissions->showPagePermissionIsGranted()) {
$visiblePages[] = $page;
}
}
$view->assign('visiblePages', $visiblePages);
} else {
$hasNewPagesData = false;
$types = $this->getTypeSelectData($pageUid, $request);
$filteredTypes = [];
$types = $this->filterTypesThatOnlyRequireTitle($types, $filteredTypes);
$view->assign('pageTypes', $types);
$view->assign('filteredTypes', $filteredTypes);
$view->assign('wizardConfiguration', ['positionData' => ['pageUid' => $pageUid, 'insertPosition' => 'inside']]);
}
$view->assign('hasNewPagesData', $hasNewPagesData);
}
return $view->renderResponse('Page/NewPages');
}
/**
* Persist new pages in DB
*
* @param array $newPagesData Data array with title and page type
* @param int $pageUid Uid of page new pages should be added in
* @param bool $afterExisting True if new pages should be created after existing pages
* @param bool $hidePages True if new pages should be set to hidden
* @param bool $hidePagesInMenu True if new pages should be set to hidden in menu
* @return bool TRUE if at least on pages has been added
*/
protected function createPages(array $newPagesData, int $pageUid, bool $afterExisting, bool $hidePages, bool $hidePagesInMenu): bool
{
$pagesCreated = false;
// Set first pid to "-1 * uid of last existing sub page" if pages should be created at end
$firstPid = $pageUid;
if ($afterExisting) {
$subPages = $this->getSubPagesOfPage($pageUid);
$lastPage = end($subPages);
if (isset($lastPage['uid']) && MathUtility::canBeInterpretedAsInteger($lastPage['uid'])) {
$firstPid = -(int)$lastPage['uid'];
}
}
$dataMap = [];
$firstRecord = true;
$previousIdentifier = '';
foreach ($newPagesData as $identifier => $data) {
if (!trim($data['title'])) {
continue;
}
$dataMap['pages'][$identifier]['hidden'] = (int)$hidePages;
$dataMap['pages'][$identifier]['nav_hide'] = (int)$hidePagesInMenu;
$dataMap['pages'][$identifier]['title'] = $data['title'];
$dataMap['pages'][$identifier]['doktype'] = $data['doktype'];
if ($firstRecord) {
$firstRecord = false;
$dataMap['pages'][$identifier]['pid'] = $firstPid;
} else {
$dataMap['pages'][$identifier]['pid'] = '-' . $previousIdentifier;
}
$previousIdentifier = $identifier;
}
if (!empty($dataMap)) {
$pagesCreated = true;
$dataHandler = GeneralUtility::makeInstance(DataHandler::class);
$dataHandler->start($dataMap, []);
$dataHandler->process_datamap();
BackendUtility::setUpdateSignal('updatePageTree');
}
return $pagesCreated;
}
protected function filterTypesThatOnlyRequireTitle(array $selectData, array &$filteredTypes): array
{
$pageSchema = $this->tcaSchemaFactory->get('pages');
foreach ($selectData as $group => $types) {
foreach ($types as $index => $type) {
$typeValue = (string)$type['value'];
$schema = $pageSchema->getSubSchema($typeValue);
foreach ($schema->getFields() as $field) {
if ($field->isRequired() && $field->getName() !== 'title') {
unset($selectData[$group][$index]);
$filteredTypes[$typeValue] = $type['label'];
continue 2;
}
}
}
}
// Remove empty categories
$selectData = array_filter($selectData, static fn(array $types) => count($types) > 0);
return $selectData;
}
/**
* Page selector type data
*/
protected function getTypeSelectData(int $pageUid, ServerRequestInterface $request): array
{
$formDataGroup = GeneralUtility::makeInstance(OnTheFly::class);
$formDataGroup->setProviderList([
InitializeProcessedTca::class,
DatabaseParentPageRow::class,
DatabaseUserPermissionCheck::class,
DatabaseEffectivePid::class,
UserTsConfig::class,
PageTsConfig::class,
DatabaseRowInitializeNew::class,
DatabaseUniqueUidNewRow::class,
TcaSelectItems::class,
]);
$selectItems = $this->formDataCompiler->compile(
[
'command' => 'new',
'request' => $request,
'tableName' => 'pages',
'vanillaUid' => $pageUid,
],
$formDataGroup
)['processedTca']['columns']['doktype']['config']['items'] ?? [];
$groupedData = [];
$groupLabel = '';
foreach ($selectItems as $selectItem) {
// If it is a group, save the group label for the children underneath.
if ($selectItem['value'] === '--div--') {
// Dividers defined inside the items array are not translated
// by the GroupAndSortService.
$groupLabel = $this->getLanguageService()->sL($selectItem['label']);
} else {
$groupedData[$groupLabel][] = $selectItem;
}
}
return $groupedData;
}
/**
* Get a list of sub pages with some all fields from given page.
* Fetch all data fields for full page icon display
*
* @param int $pageUid Get sub pages from this pages
*/
protected function getSubPagesOfPage(int $pageUid): array
{
$queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('pages');
$queryBuilder->getRestrictions()->removeAll()->add(GeneralUtility::makeInstance(DeletedRestriction::class));
return $queryBuilder->select('*')
->from('pages')
->where(
$queryBuilder->expr()->eq(
'pid',
$queryBuilder->createNamedParameter($pageUid, Connection::PARAM_INT)
),
$queryBuilder->expr()->eq(
$this->tcaSchemaFactory->get('pages')->getCapability(TcaSchemaCapability::Language)->getLanguageField()->getName(),
$queryBuilder->createNamedParameter(0, Connection::PARAM_INT)
)
)
->orderBy('sorting')
->executeQuery()
->fetchAllAssociative();
}
protected function getLanguageService(): LanguageService
{
return $GLOBALS['LANG'];
}
protected function getBackendUser(): BackendUserAuthentication
{
return $GLOBALS['BE_USER'];
}
}
@@ -0,0 +1,208 @@
<?php
declare(strict_types=1);
/*
* 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 TYPO3\CMS\Backend\Controller\Page;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Backend\Attribute\AsController;
use TYPO3\CMS\Backend\Routing\PreviewUriBuilder;
use TYPO3\CMS\Backend\Template\Components\ComponentFactory;
use TYPO3\CMS\Backend\Template\ModuleTemplateFactory;
use TYPO3\CMS\Backend\Utility\BackendUtility;
use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
use TYPO3\CMS\Core\Database\Connection;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Database\Query\Restriction\DeletedRestriction;
use TYPO3\CMS\Core\DataHandling\DataHandler;
use TYPO3\CMS\Core\Imaging\IconFactory;
use TYPO3\CMS\Core\Localization\LanguageService;
use TYPO3\CMS\Core\Type\Bitmask\Permission;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* "Sort sub pages" controller - reachable from context menu "more" on page records
* @internal This class is a specific Backend controller implementation and is not considered part of the Public TYPO3 API.
*/
#[AsController]
readonly class SortSubPagesController
{
public function __construct(
protected IconFactory $iconFactory,
protected ComponentFactory $componentFactory,
protected ModuleTemplateFactory $moduleTemplateFactory,
protected ConnectionPool $connectionPool,
) {}
/**
* Main function Handling input variables and rendering main view.
*/
public function mainAction(ServerRequestInterface $request): ResponseInterface
{
$view = $this->moduleTemplateFactory->create($request);
$backendUser = $this->getBackendUser();
$parentPageUid = (int)($request->getQueryParams()['id'] ?? 0);
// Show only if there is a valid page and if this page may be viewed by the user
$pageInformation = BackendUtility::readPageAccess($parentPageUid, $backendUser->getPagePermsClause(Permission::PAGE_SHOW));
if (!is_array($pageInformation)) {
// User has no permission on parent page, should not happen, just render an empty page
return $view->renderResponse('Dummy/Index');
}
// Doc header handling
$view->getDocHeaderComponent()->setPageBreadcrumb($pageInformation);
$view->addButtonToButtonBar(
$this->componentFactory->createViewButton(
PreviewUriBuilder::create($pageInformation)
->withRootLine(BackendUtility::BEgetRootLine($parentPageUid))
->buildDispatcherDataAttributes() ?? []
)
);
$isInWorkspace = $backendUser->workspace !== 0;
$view->assignMultiple([
'isInWorkspace' => $isInWorkspace,
'maxTitleLength' => $backendUser->uc['titleLen'] ?? 20,
'parentPageUid' => $parentPageUid,
'dateFormat' => $GLOBALS['TYPO3_CONF_VARS']['SYS']['ddmmyy'],
'timeFormat' => $GLOBALS['TYPO3_CONF_VARS']['SYS']['hhmm'],
]);
if (!$isInWorkspace) {
// Apply new sorting if given
$newSortBy = $request->getQueryParams()['newSortBy'] ?? null;
if ($newSortBy && in_array($newSortBy, ['title', 'subtitle', 'nav_title', 'crdate', 'tstamp'], true)) {
$this->sortSubPagesByField($parentPageUid, (string)$newSortBy);
} elseif ($newSortBy && $newSortBy === 'reverseCurrentSorting') {
$this->reverseSortingOfPages($parentPageUid);
}
// Get sub pages, loop through them and add page/user specific permission details
$pageRecords = $this->getSubPagesOfPage($parentPageUid);
$hasInvisiblePage = false;
$subPages = [];
foreach ($pageRecords as $page) {
$pageWithPermissions = [];
$pageWithPermissions['record'] = $page;
$calculatedPermissions = new Permission($backendUser->calcPerms($page));
$pageWithPermissions['canEdit'] = $backendUser->isAdmin() || $calculatedPermissions->editPagePermissionIsGranted();
$canSeePage = $backendUser->isAdmin() || $calculatedPermissions->showPagePermissionIsGranted();
if ($canSeePage) {
$subPages[] = $pageWithPermissions;
} else {
$hasInvisiblePage = true;
}
}
$view->assign('subPages', $subPages);
$view->assign('hasInvisiblePage', $hasInvisiblePage);
}
return $view->renderResponse('Page/SortSubPages');
}
/**
* Sort sub pages of given uid by field name alphabetically
*
* @param int $parentPageUid Parent page uid
* @param string $newSortBy Field name to sort by
* @throws \RuntimeException If $newSortBy does not validate
*/
protected function sortSubPagesByField(int $parentPageUid, string $newSortBy)
{
if (!in_array($newSortBy, ['title', 'subtitle', 'nav_title', 'crdate', 'tstamp'], true)) {
throw new \RuntimeException(
'New sort by must be one of "title", "subtitle", "nav_title", "crdate" or tstamp',
1498924810
);
}
$subPages = $this->getSubPagesOfPage($parentPageUid, $newSortBy);
if (!empty($subPages)) {
$subPages = array_reverse($subPages);
$this->persistNewSubPageOrder($parentPageUid, $subPages);
}
}
/**
* Reverse current sorting of sub pages
*
* @param int $parentPageUid Parent page uid
*/
protected function reverseSortingOfPages(int $parentPageUid)
{
$subPages = $this->getSubPagesOfPage($parentPageUid);
if (!empty($subPages)) {
$this->persistNewSubPageOrder($parentPageUid, $subPages);
}
}
/**
* Store new sub page order
*
* @param int $parentPageUid Parent page uid
* @param array $subPages List of sub pages in new order
*/
protected function persistNewSubPageOrder(int $parentPageUid, array $subPages)
{
$commandArray = [];
foreach ($subPages as $subPage) {
$commandArray['pages'][$subPage['uid']]['move'] = $parentPageUid;
}
$dataHandler = GeneralUtility::makeInstance(DataHandler::class);
$dataHandler->start([], $commandArray);
$dataHandler->process_cmdmap();
BackendUtility::setUpdateSignal('updatePageTree');
}
/**
* Get a list of sub pages with some all fields from given page.
* Fetch all data fields for full page icon display
*
* @param int $parentPageUid Get sub pages from this pages
* @param string $orderBy Order pages by this field
*/
protected function getSubPagesOfPage(int $parentPageUid, string $orderBy = 'sorting'): array
{
$queryBuilder = $this->connectionPool->getQueryBuilderForTable('pages');
$queryBuilder->getRestrictions()->removeAll()->add(GeneralUtility::makeInstance(DeletedRestriction::class));
return $queryBuilder->select('*')
->from('pages')
->where(
$queryBuilder->expr()->or(
$queryBuilder->expr()->eq('language_tag', $queryBuilder->createNamedParameter(\Local\Multilanguage\Service\DefaultLanguageTagService::getTag())),
$queryBuilder->expr()->eq('language_tag', $queryBuilder->createNamedParameter('')),
),
$queryBuilder->expr()->eq(
'pid',
$queryBuilder->createNamedParameter($parentPageUid, Connection::PARAM_INT)
)
)
->orderBy($orderBy)
->executeQuery()
->fetchAllAssociative();
}
protected function getLanguageService(): LanguageService
{
return $GLOBALS['LANG'];
}
protected function getBackendUser(): BackendUserAuthentication
{
return $GLOBALS['BE_USER'];
}
}
+800
View File
@@ -0,0 +1,800 @@
<?php
declare(strict_types=1);
/*
* 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 TYPO3\CMS\Backend\Controller\Page;
use Psr\EventDispatcher\EventDispatcherInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Backend\Attribute\AsController;
use TYPO3\CMS\Backend\Controller\Event\AfterPageTreeItemsPreparedEvent;
use TYPO3\CMS\Backend\Dto\Tree\Label\Label;
use TYPO3\CMS\Backend\Dto\Tree\PageTreeItem;
use TYPO3\CMS\Backend\Dto\Tree\TreeItem;
use TYPO3\CMS\Backend\Form\FormDataCompiler;
use TYPO3\CMS\Backend\Form\FormDataGroup\OnTheFly;
use TYPO3\CMS\Backend\Form\FormDataProvider\DatabaseEffectivePid;
use TYPO3\CMS\Backend\Form\FormDataProvider\DatabaseParentPageRow;
use TYPO3\CMS\Backend\Form\FormDataProvider\DatabaseRowInitializeNew;
use TYPO3\CMS\Backend\Form\FormDataProvider\DatabaseUniqueUidNewRow;
use TYPO3\CMS\Backend\Form\FormDataProvider\InitializeProcessedTca;
use TYPO3\CMS\Backend\Form\FormDataProvider\PageTsConfig;
use TYPO3\CMS\Backend\Form\FormDataProvider\TcaSelectItems;
use TYPO3\CMS\Backend\Form\FormDataProvider\UserTsConfig;
use TYPO3\CMS\Backend\Routing\UriBuilder;
use TYPO3\CMS\Backend\Tree\Repository\PageTreeRepository;
use TYPO3\CMS\Backend\Utility\BackendUtility;
use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
use TYPO3\CMS\Core\Authentication\JsConfirmation;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Database\Query\Restriction\DocumentTypeExclusionRestriction;
use TYPO3\CMS\Core\DataHandling\PageDoktypeRegistry;
use TYPO3\CMS\Core\Exception\SiteNotFoundException;
use TYPO3\CMS\Core\Http\JsonResponse;
use TYPO3\CMS\Core\Imaging\IconFactory;
use TYPO3\CMS\Core\Imaging\IconSize;
use TYPO3\CMS\Core\Localization\LanguageService;
use TYPO3\CMS\Core\Site\SiteFinder;
use TYPO3\CMS\Core\Type\Bitmask\Permission;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Core\Utility\MathUtility;
/**
* Controller providing data to the page tree
* @internal This class is a specific Backend controller implementation and is not considered part of the Public TYPO3 API.
*/
#[AsController]
class TreeController
{
/**
* Option to use the nav_title field for outputting in the tree items, set via userTS.
*/
protected bool $useNavTitle = false;
/**
* Option to prefix the page ID when outputting the tree items, set via userTS.
*/
protected bool $addIdAsPrefix = false;
/**
* Option to prefix the domain name of sys_domains when outputting the tree items, set via userTS.
*/
protected bool $addDomainName = false;
/**
* Option to add the rootline path above each mount point, set via userTS.
*/
protected bool $showMountPathAboveMounts = false;
/**
* A list of pages not to be shown.
*/
protected array $hiddenRecords = [];
/**
* An array of labels for a branch in the tree, set via userTS.
*/
protected array $labels = [];
/**
* Number of tree levels which should be returned on the first page tree load
*/
protected int $levelsToFetch = 2;
/**
* When set to true all nodes returend by API will be expanded
*/
protected bool $expandAllNodes = false;
/**
* Used in the record link picker to limit the page tree only to a specific list
* of alternative entry points for selecting only from a list of pages
*/
protected array $alternativeEntryPoints = [];
protected PageTreeRepository $pageTreeRepository;
protected bool $userHasAccessToModifyPagesAndToDefaultLanguage = false;
public function __construct(
protected readonly IconFactory $iconFactory,
protected readonly UriBuilder $uriBuilder,
protected readonly EventDispatcherInterface $eventDispatcher,
protected readonly SiteFinder $siteFinder,
protected readonly PageDoktypeRegistry $pageDoktypeRegistry,
protected readonly FormDataCompiler $formDataCompiler,
) {}
protected function initializeConfiguration(ServerRequestInterface $request)
{
if ($request->getQueryParams()['readOnly'] ?? false) {
$this->getBackendUser()->initializeWebmountsForElementBrowser();
}
if ($request->getQueryParams()['alternativeEntryPoints'] ?? false) {
$this->alternativeEntryPoints = $request->getQueryParams()['alternativeEntryPoints'];
$this->alternativeEntryPoints = array_filter($this->alternativeEntryPoints, function (int $pageId): bool {
return $this->getBackendUser()->isInWebMount($pageId) !== null;
});
$this->alternativeEntryPoints = array_map(intval(...), $this->alternativeEntryPoints);
$this->alternativeEntryPoints = array_unique($this->alternativeEntryPoints);
}
$userTsConfig = $this->getBackendUser()->getTSConfig();
$this->hiddenRecords = GeneralUtility::intExplode(
',',
(string)($userTsConfig['options.']['hideRecords.']['pages'] ?? ''),
true
);
$this->labels = $userTsConfig['options.']['pageTree.']['label.'] ?? [];
$this->addIdAsPrefix = (bool)($userTsConfig['options.']['pageTree.']['showPageIdWithTitle'] ?? false);
$this->addDomainName = (bool)($userTsConfig['options.']['pageTree.']['showDomainNameWithTitle'] ?? false);
$this->useNavTitle = (bool)($userTsConfig['options.']['pageTree.']['showNavTitle'] ?? false);
$this->showMountPathAboveMounts = (bool)($userTsConfig['options.']['pageTree.']['showPathAboveMounts'] ?? false);
$this->userHasAccessToModifyPagesAndToDefaultLanguage = $this->getBackendUser()->check('tables_modify', 'pages') && $this->getBackendUser()->checkLanguageAccess(0);
}
/**
* Returns page tree configuration in JSON
*/
public function fetchConfigurationAction(ServerRequestInterface $request): ResponseInterface
{
$backendUser = $this->getBackendUser();
$userTsConfig = $backendUser->getTSConfig();
// Check if translation search feature is generally available (TSconfig setting)
$translationSearchAvailable = (bool)($userTsConfig['options.']['pageTree.']['searchInTranslatedPages'] ?? true);
// Determine if translation search is enabled by the user preference - otherwise TSconfig setting applies
$translationSearchEnabled = $translationSearchAvailable
&& (
!isset($backendUser->uc['pageTree_searchInTranslatedPages'])
|| $backendUser->uc['pageTree_searchInTranslatedPages']
);
// Check if frontend URI search feature is generally available (TSconfig setting)
$frontendUriSearchAvailable = (bool)($userTsConfig['options.']['pageTree.']['searchByFrontendUri'] ?? true);
// Determine if frontend URI search is enabled by the user preference - otherwise TSconfig setting applies
$frontendUriSearchEnabled = $frontendUriSearchAvailable
&& (
!isset($backendUser->uc['pageTree_searchByFrontendUri'])
|| $backendUser->uc['pageTree_searchByFrontendUri']
);
// Build language list from site configuration
$languages = [];
$currentLanguageTag = $_COOKIE['pageTreeLang'] ?? '';
try {
$siteFinder = GeneralUtility::makeInstance(SiteFinder::class);
$sites = $siteFinder->getAllSites();
foreach ($sites as $site) {
foreach ($site->getAllLanguages() as $lang) {
$tag = $lang->getLanguageTag();
$languages[] = [
'languageTag' => $tag,
'title' => $lang->getTitle(),
'flag' => $lang->getFlagIdentifier(),
];
}
break;
}
} catch (\Throwable) {}
$dataUrlParams = [];
if ($currentLanguageTag !== '') {
$dataUrlParams['language'] = $currentLanguageTag;
}
$configuration = [
'allowDragMove' => $this->isDragMoveAllowed(),
'doktypes' => $this->getDokTypes($request),
'displayDeleteConfirmation' => $backendUser->jsConfirmation(JsConfirmation::DELETE),
'temporaryMountPoint' => $this->getMountPointPath((int)($backendUser->uc['pageTree_temporaryMountPoint'] ?? 0)),
'showIcons' => true,
'dataUrl' => (string)$this->uriBuilder->buildUriFromRoute('ajax_page_tree_data', $dataUrlParams),
'rootlineUrl' => (string)$this->uriBuilder->buildUriFromRoute('ajax_page_tree_rootline'),
'filterUrl' => (string)$this->uriBuilder->buildUriFromRoute('ajax_page_tree_filter'),
'setTemporaryMountPointUrl' => (string)$this->uriBuilder->buildUriFromRoute('ajax_page_tree_set_temporary_mount_point'),
'searchInTranslatedPagesEnabled' => $translationSearchEnabled,
'searchInTranslatedPagesAvailable' => $translationSearchAvailable,
'searchByFrontendUriEnabled' => $frontendUriSearchEnabled,
'searchByFrontendUriAvailable' => $frontendUriSearchAvailable,
'languages' => $languages,
'currentLanguage' => $currentLanguageTag,
];
return new JsonResponse($configuration);
}
public function fetchReadOnlyConfigurationAction(ServerRequestInterface $request): ResponseInterface
{
$entryPoints = (string)($request->getQueryParams()['alternativeEntryPoints'] ?? '');
$entryPoints = GeneralUtility::intExplode(',', $entryPoints, true);
$additionalArguments = [
'readOnly' => 1,
];
if (!empty($entryPoints)) {
$additionalArguments['alternativeEntryPoints'] = $entryPoints;
}
$configuration = [
'displayDeleteConfirmation' => $this->getBackendUser()->jsConfirmation(JsConfirmation::DELETE),
'temporaryMountPoint' => $this->getMountPointPath((int)($this->getBackendUser()->uc['pageTree_temporaryMountPoint'] ?? 0)),
'showIcons' => true,
'dataUrl' => (string)$this->uriBuilder->buildUriFromRoute('ajax_page_tree_data', $additionalArguments),
'filterUrl' => (string)$this->uriBuilder->buildUriFromRoute('ajax_page_tree_filter', $additionalArguments),
'setTemporaryMountPointUrl' => (string)$this->uriBuilder->buildUriFromRoute('ajax_page_tree_set_temporary_mount_point'),
'nonViewableDoktypes' => $this->pageDoktypeRegistry->getNonViewableDoktypes(),
];
return new JsonResponse($configuration);
}
/**
* Returns the list of doktypes to display in page tree toolbar drag area,
* automatically determined based on the user's group permissions.
*/
protected function getDokTypes(ServerRequestInterface $request): array
{
$formDataGroup = GeneralUtility::makeInstance(OnTheFly::class);
// Skip DatabaseUserPermissionCheck::class to return doktypes even if the user cannot create pages at root level
$formDataGroup->setProviderList([
InitializeProcessedTca::class,
DatabaseParentPageRow::class,
DatabaseEffectivePid::class,
UserTsConfig::class,
PageTsConfig::class,
DatabaseRowInitializeNew::class,
DatabaseUniqueUidNewRow::class,
TcaSelectItems::class,
]);
try {
$doktypes = $this->formDataCompiler
->compile(
[
'command' => 'new',
'request' => $request,
'tableName' => 'pages',
'vanillaUid' => 0,
],
$formDataGroup
)['processedTca']['columns']['doktype']['config']['items'] ?? [];
} catch (\Exception) {
return [];
}
return array_values(
array_map(
static fn(array $doktype) => [
'nodeType' => $doktype['value'],
'icon' => $doktype['icon'] ?? '',
'title' => $doktype['label'] ?? '',
],
array_filter(
$doktypes,
static fn(array $doktype) => ($doktype['value'] ?? '') !== '--div--' && ($doktype['value'] ?? '') !== ''
)
)
);
}
/**
* Returns JSON representing page tree
*/
public function fetchDataAction(ServerRequestInterface $request): ResponseInterface
{
$this->initializeConfiguration($request);
$languageParam = $request->getQueryParams()['language'] ?? '';
$languageTag = ($languageParam !== '' && $languageParam !== '0') ? $languageParam : null;
$items = [];
$parentIdentifier = $request->getQueryParams()['parent'] ?? null;
if ($parentIdentifier) {
$parentDepth = (int)($request->getQueryParams()['depth'] ?? 0);
// Fetching a part of a page tree
$entryPoints = $this->getAllEntryPointPageTrees((int)$parentIdentifier);
$mountPid = (int)($request->getQueryParams()['mount'] ?? 0);
$this->levelsToFetch = $parentDepth + $this->levelsToFetch;
foreach ($entryPoints as $page) {
$items[] = $this->pagesToFlatArray($page, $mountPid, $parentDepth);
}
} else {
$entryPoints = $this->getAllEntryPointPageTrees();
foreach ($entryPoints as $page) {
$items[] = $this->pagesToFlatArray($page, (int)$page['uid']);
}
}
$items = array_merge(...$items);
if ($languageTag !== null) {
$this->applyTranslationOverlay($items, $languageTag);
}
return new JsonResponse($this->getPostProcessedPageItems($request, $items));
}
private function applyTranslationOverlay(array &$items, string $languageTag): void
{
$pageIds = [];
foreach ($items as $item) {
$uid = (int)($item['identifier'] ?? 0);
if ($uid > 0) {
$pageIds[] = $uid;
}
}
if (empty($pageIds)) {
return;
}
$queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable('pages');
$translations = $queryBuilder
->select('l10n_parent', 'title', 'nav_title', 'language_tag')
->from('pages')
->where(
$queryBuilder->expr()->in('l10n_parent', $pageIds),
$queryBuilder->expr()->eq('language_tag', $queryBuilder->createNamedParameter($languageTag))
)
->executeQuery()
->fetchAllAssociative();
$translationMap = [];
foreach ($translations as $trans) {
$translationMap[(int)$trans['l10n_parent']] = $trans;
}
foreach ($items as &$item) {
$page = $item['_page'] ?? [];
$uid = (int)($page['uid'] ?? (int)($item['identifier'] ?? 0));
$trans = $translationMap[$uid] ?? null;
if ($trans) {
$item['name'] = $trans['title'];
if (!empty($trans['nav_title'])) {
$item['name'] = $trans['nav_title'];
$item['nameSourceField'] = 'nav_title';
}
$item['_page']['title'] = $trans['title'];
$item['_page']['nav_title'] = $trans['nav_title'] ?? '';
$item['_page']['_translatedTitle'] = $trans['title'];
}
}
}
/**
* Returns JSON representing page rootline
*/
public function fetchRootlineAction(ServerRequestInterface $request): ResponseInterface
{
$identifier = (string)($request->getQueryParams()['identifier'] ?? '');
if (!MathUtility::canBeInterpretedAsInteger($identifier)) {
return new JsonResponse(null, 400);
}
$pageId = (int)$identifier;
if ($pageId === 0) {
return new JsonResponse(['rootline' => ['0']]);
}
$rootline = BackendUtility::BEgetRootLine((int)$identifier);
if ($rootline === []) {
return new JsonResponse(null, 404);
}
return new JsonResponse([
'rootline' => array_map(strval(...), array_column(array_reverse($rootline), 'uid')),
]);
}
/**
* Returns JSON representing page tree filtered by keyword
*/
public function filterDataAction(ServerRequestInterface $request): ResponseInterface
{
$searchQuery = $request->getQueryParams()['q'] ?? '';
if (trim($searchQuery) === '') {
return new JsonResponse([]);
}
$this->initializeConfiguration($request);
$this->expandAllNodes = true;
$items = [];
$entryPoints = $this->getAllEntryPointPageTrees(0, $searchQuery);
foreach ($entryPoints as $page) {
if (!empty($page)) {
$items[] = $this->pagesToFlatArray($page, (int)$page['uid']);
}
}
$items = array_merge(...$items);
return new JsonResponse($this->getPostProcessedPageItems($request, $items));
}
/**
* Sets a temporary mount point
*
* @throws \RuntimeException
*/
public function setTemporaryMountPointAction(ServerRequestInterface $request): ResponseInterface
{
if (empty($request->getParsedBody()['pid'])) {
throw new \RuntimeException(
'Required "pid" parameter is missing.',
1511792197
);
}
$pid = (int)$request->getParsedBody()['pid'];
$this->getBackendUser()->uc['pageTree_temporaryMountPoint'] = $pid;
$this->getBackendUser()->writeUC();
$response = [
'mountPointPath' => $this->getMountPointPath($pid),
];
return new JsonResponse($response);
}
/**
* Converts nested tree structure produced by PageTreeRepository to a flat, one level array
* and also adds visual representation information to the data.
*
* The result is intended to be used as JSON result - dumping data directly to HTML might lead to XSS!
*
* @param array $page
* @param int $entryPoint
* @param int $depth
*/
protected function pagesToFlatArray(array $page, int $entryPoint, int $depth = 0): array
{
$backendUser = $this->getBackendUser();
$pageId = (int)$page['uid'];
if (in_array($pageId, $this->hiddenRecords, true)) {
return [];
}
$stopPageTree = !empty($page['php_tree_stop']) && $depth > 0;
$identifier = $entryPoint . '_' . $pageId;
$suffix = '';
$prefix = '';
$nameSourceField = 'title';
$visibleText = $page['title'];
$tooltip = BackendUtility::titleAttribForPages($page, '', false, $this->useNavTitle);
if ($pageId !== 0) {
$icon = $this->iconFactory->getIconForRecord('pages', $page, IconSize::SMALL);
} else {
$icon = $this->iconFactory->getIcon('apps-pagetree-root', IconSize::SMALL);
}
if ($this->useNavTitle && trim($page['nav_title'] ?? '') !== '') {
$nameSourceField = 'nav_title';
$visibleText = $page['nav_title'];
}
if (trim($visibleText) === '') {
$visibleText = htmlspecialchars('[' . $this->getLanguageService()->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.no_title') . ']');
}
if ($this->addDomainName && ($page['is_siteroot'] ?? false)) {
$domain = $this->getDomainNameForPage($pageId);
$suffix = $domain !== '' ? ' [' . $domain . ']' : '';
}
$lockInfo = BackendUtility::isRecordLocked('pages', $pageId);
if (is_array($lockInfo)) {
$tooltip .= ' - ' . $lockInfo['msg'];
}
if ($this->addIdAsPrefix) {
$prefix = '[' . $pageId . '] ';
}
$labels = [];
if (!empty($this->labels[$pageId . '.']) && isset($this->labels[$pageId . '.']['label']) && trim($this->labels[$pageId . '.']['label']) !== '') {
$labels[] = new Label(
label: $this->getLanguageService()->sL($this->labels[$pageId . '.']['label']),
color: (string)($this->labels[$pageId . '.']['color'] ?? '#ff8700'),
);
}
$editable = false;
if ($pageId !== 0) {
$editable = $this->userHasAccessToModifyPagesAndToDefaultLanguage && $backendUser->doesUserHaveAccess($page, Permission::PAGE_EDIT);
}
$items = [];
$item = [
// identifier is not only used for pages, therefore it's a string
'identifier' => (string)$pageId,
'parentIdentifier' => (string)($page['pid'] ?? ''),
'recordType' => 'pages',
'name' => $visibleText,
'prefix' => !empty($prefix) ? htmlspecialchars($prefix) : '',
'suffix' => !empty($suffix) ? htmlspecialchars($suffix) : '',
'tooltip' => $tooltip,
'depth' => $depth,
'icon' => $icon->getIdentifier(),
'overlayIcon' => $icon->getOverlayIcon() ? $icon->getOverlayIcon()->getIdentifier() : '',
'editable' => $editable,
'deletable' => $backendUser->doesUserHaveAccess($page, Permission::PAGE_DELETE),
'labels' => $labels,
// _page is only for use in events so they do not need to fetch those
// records again. The property will be removed from the final payload.
'_page' => $page,
// _translationLanguageUids contains the language UIDs for translations that matched (only populated during search)
'_translationLanguageUids' => $this->pageTreeRepository->getTranslationMatches($pageId),
'doktype' => (int)($page['doktype'] ?? 0),
'nameSourceField' => $nameSourceField,
'mountPoint' => $entryPoint,
'workspaceId' => !empty($page['t3ver_oid']) ? $page['t3ver_oid'] : $pageId,
];
if (!empty($page['_children']) || $this->pageTreeRepository->hasChildren($pageId)) {
$item['hasChildren'] = true;
if ($depth >= $this->levelsToFetch) {
$page = $this->pageTreeRepository->getTreeLevels($page, 1);
}
}
if (is_array($lockInfo)) {
$item['locked'] = true;
}
if ($stopPageTree) {
$item['stopPageTree'] = true;
}
if ($depth === 0) {
if ($this->showMountPathAboveMounts) {
$item['note'] = $this->getMountPointPath($pageId);
}
}
$items[] = $item;
if (!$stopPageTree && is_array($page['_children']) && !empty($page['_children']) && ($depth < $this->levelsToFetch || $this->expandAllNodes)) {
$items[key($items)]['loaded'] = true;
foreach ($page['_children'] as $child) {
$items = array_merge($items, $this->pagesToFlatArray($child, $entryPoint, $depth + 1));
}
}
return $items;
}
protected function initializePageTreeRepository(): PageTreeRepository
{
$backendUser = $this->getBackendUser();
$userTsConfig = $backendUser->getTSConfig();
$excludedDocumentTypes = GeneralUtility::intExplode(',', (string)($userTsConfig['options.']['pageTree.']['excludeDoktypes'] ?? ''), true);
$additionalQueryRestrictions = [];
if ($excludedDocumentTypes !== []) {
$additionalQueryRestrictions[] = GeneralUtility::makeInstance(DocumentTypeExclusionRestriction::class, $excludedDocumentTypes);
}
$pageTreeRepository = GeneralUtility::makeInstance(
PageTreeRepository::class,
$backendUser->workspace,
[],
$additionalQueryRestrictions
);
$pageTreeRepository->setAdditionalWhereClause($backendUser->getPagePermsClause(Permission::PAGE_SHOW));
return $pageTreeRepository;
}
/**
* Fetches all pages for all tree entry points the user is allowed to see
*
* @param string $query The search query can either be a string to be found in the title or the nav_title of a page or the uid of a page.
*/
protected function getAllEntryPointPageTrees(int $startPid = 0, string $query = ''): array
{
$this->pageTreeRepository ??= $this->initializePageTreeRepository();
$backendUser = $this->getBackendUser();
if ($startPid === 0) {
$startPid = (int)($backendUser->uc['pageTree_temporaryMountPoint'] ?? 0);
}
$entryPointIds = null;
if ($startPid > 0) {
$entryPointIds = [$startPid];
} elseif (!empty($this->alternativeEntryPoints)) {
$entryPointIds = $this->alternativeEntryPoints;
}
$permClause = $backendUser->getPagePermsClause(Permission::PAGE_SHOW);
if ($query !== '') {
$this->levelsToFetch = 999;
$this->pageTreeRepository->fetchFilteredTree(
$query,
$this->getAllowedMountPoints(),
$permClause
);
}
$rootRecord = [
'uid' => 0,
'title' => $GLOBALS['TYPO3_CONF_VARS']['SYS']['sitename'] ?: 'TYPO3',
];
$entryPointRecords = [];
$mountPoints = [];
if ($entryPointIds === null) {
//watch out for deleted pages returned as webmount
$mountPoints = $backendUser->getWebmounts();
$mountPoints = array_filter($mountPoints, fn(int $id): bool => !in_array($id, $this->hiddenRecords, true));
// Switch to multiple-entryPoint-mode if the rootPage is to be mounted.
// (other mounts would appear duplicated in the pid = 0 tree otherwise)
if (in_array(0, $mountPoints, true)) {
$entryPointIds = $mountPoints;
}
}
if ($entryPointIds === null) {
if ($query !== '') {
$rootRecord = $this->pageTreeRepository->getTree(0, null, $mountPoints);
} else {
$rootRecord = $this->pageTreeRepository->getTreeLevels($rootRecord, $this->levelsToFetch, $mountPoints);
}
$mountPointOrdering = array_flip($mountPoints);
if (isset($rootRecord['_children'])) {
usort($rootRecord['_children'], static function ($a, $b) use ($mountPointOrdering) {
return ($mountPointOrdering[$a['uid']] ?? 0) <=> ($mountPointOrdering[$b['uid']] ?? 0);
});
}
$entryPointRecords[] = $rootRecord;
} else {
$entryPointIds = array_filter($entryPointIds, fn(int $id): bool => !in_array($id, $this->hiddenRecords, true));
foreach ($entryPointIds as $k => $entryPointId) {
if ($entryPointId === 0) {
$entryPointRecord = $rootRecord;
} else {
$entryPointRecord = BackendUtility::getRecordWSOL('pages', $entryPointId, '*', $permClause);
if ($entryPointRecord !== null && !$backendUser->isInWebMount($entryPointId)) {
$entryPointRecord = null;
}
if ($entryPointRecord === null) {
continue;
}
}
$entryPointRecord['uid'] = (int)$entryPointRecord['uid'];
if ($query === '') {
$entryPointRecord = $this->pageTreeRepository->getTreeLevels($entryPointRecord, $this->levelsToFetch);
} else {
$entryPointRecord = $this->pageTreeRepository->getTree($entryPointRecord['uid'], null, $entryPointIds);
}
if ($entryPointRecord !== []) {
$entryPointRecords[$k] = $entryPointRecord;
}
}
}
return $entryPointRecords;
}
/**
* Returns the first configured domain name for a page
*/
protected function getDomainNameForPage(int $pageId): string
{
try {
$site = $this->siteFinder->getSiteByRootPageId($pageId);
return (string)$site->getBase();
} catch (SiteNotFoundException) {
// No site found
}
return '';
}
/**
* Returns the mount point path for a temporary mount or the given id
*/
protected function getMountPointPath(int $uid): string
{
if ($uid <= 0) {
return '';
}
$rootline = array_reverse(BackendUtility::BEgetRootLine($uid));
array_shift($rootline);
$path = [];
foreach ($rootline as $rootlineElement) {
$record = BackendUtility::getRecordWSOL('pages', $rootlineElement['uid'], 'title, nav_title', '', true, true);
$text = $record['title'];
if ($this->useNavTitle && trim($record['nav_title'] ?? '') !== '') {
$text = $record['nav_title'];
}
$path[] = htmlspecialchars($text);
}
return '/' . implode('/', $path);
}
/**
* Check if drag-move in the svg tree is allowed for the user
*/
protected function isDragMoveAllowed(): bool
{
$backendUser = $this->getBackendUser();
return $backendUser->isAdmin()
|| ($backendUser->check('tables_modify', 'pages') && $backendUser->checkLanguageAccess(0));
}
/**
* Get allowed mountpoints. Returns temporary mountpoint when temporary mountpoint is used.
*
* @return int[]
*/
protected function getAllowedMountPoints(): array
{
$mountPoints = (int)($this->getBackendUser()->uc['pageTree_temporaryMountPoint'] ?? 0);
if (!$mountPoints) {
if (!empty($this->alternativeEntryPoints)) {
return $this->alternativeEntryPoints;
}
return $this->getBackendUser()->getWebmounts();
}
return [$mountPoints];
}
protected function getPostProcessedPageItems(ServerRequestInterface $request, array $items): array
{
return array_map(
static function (array $item): PageTreeItem {
return new PageTreeItem(
// TreeItem
new TreeItem(
identifier: $item['identifier'],
parentIdentifier: (string)($item['parentIdentifier'] ?? ''),
recordType: (string)($item['recordType'] ?? ''),
name: (string)($item['name'] ?? ''),
note: (string)($item['note'] ?? ''),
prefix: (string)($item['prefix'] ?? ''),
suffix: (string)($item['suffix'] ?? ''),
tooltip: (string)($item['tooltip'] ?? ''),
depth: (int)($item['depth'] ?? 0),
hasChildren: (bool)($item['hasChildren'] ?? false),
loaded: (bool)($item['loaded'] ?? false),
editable: (bool)($item['editable'] ?? false),
deletable: (bool)($item['deletable'] ?? false),
icon: (string)($item['icon'] ?? ''),
overlayIcon: (string)($item['overlayIcon'] ?? ''),
statusInformation: (array)($item['statusInformation'] ?? []),
labels: (array)($item['labels'] ?? []),
),
// PageTreeItem
doktype: (int)($item['doktype'] ?? ''),
nameSourceField: (string)($item['nameSourceField'] ?? ''),
workspaceId: (int)($item['workspaceId'] ?? 0),
locked: (bool)($item['locked'] ?? false),
stopPageTree: (bool)($item['stopPageTree'] ?? false),
mountPoint: (int)($item['mountPoint'] ?? 0),
);
},
$this->eventDispatcher->dispatch(
new AfterPageTreeItemsPreparedEvent($request, $items)
)->getItems()
);
}
protected function getBackendUser(): BackendUserAuthentication
{
return $GLOBALS['BE_USER'];
}
protected function getLanguageService(): ?LanguageService
{
return $GLOBALS['LANG'] ?? null;
}
}
+756
View File
@@ -0,0 +1,756 @@
<?php
declare(strict_types=1);
/*
* 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 TYPO3\CMS\Backend\Controller;
use Psr\EventDispatcher\EventDispatcherInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Backend\Attribute\AsController;
use TYPO3\CMS\Backend\Clipboard\Clipboard;
use TYPO3\CMS\Backend\Context\PageContext;
use TYPO3\CMS\Backend\Controller\Event\ModifyPageLayoutContentEvent;
use TYPO3\CMS\Backend\Module\ModuleData;
use TYPO3\CMS\Backend\Module\ModuleProvider;
use TYPO3\CMS\Backend\Routing\PreviewUriBuilder;
use TYPO3\CMS\Backend\Routing\UriBuilder;
use TYPO3\CMS\Backend\Service\PageLinkMessageProvider;
use TYPO3\CMS\Backend\Template\Components\ButtonBar;
use TYPO3\CMS\Backend\Template\Components\Buttons\ButtonInterface;
use TYPO3\CMS\Backend\Template\Components\Buttons\LanguageSelectorBuilder;
use TYPO3\CMS\Backend\Template\Components\Buttons\LanguageSelectorMode;
use TYPO3\CMS\Backend\Template\Components\ComponentFactory;
use TYPO3\CMS\Backend\Template\ModuleTemplate;
use TYPO3\CMS\Backend\Template\ModuleTemplateFactory;
use TYPO3\CMS\Backend\Utility\BackendUtility;
use TYPO3\CMS\Backend\View\BackendLayoutView;
use TYPO3\CMS\Backend\View\Drawing\BackendLayoutRenderer;
use TYPO3\CMS\Backend\View\Drawing\DrawingConfiguration;
use TYPO3\CMS\Backend\View\PageLayoutContext;
use TYPO3\CMS\Backend\View\PageViewMode;
use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
use TYPO3\CMS\Core\Database\Connection;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Database\Query\Restriction\DeletedRestriction;
use TYPO3\CMS\Core\Database\Query\Restriction\WorkspaceRestriction;
use TYPO3\CMS\Core\Domain\Repository\PageRepository;
use TYPO3\CMS\Core\Imaging\IconFactory;
use TYPO3\CMS\Core\Imaging\IconSize;
use TYPO3\CMS\Core\Localization\LanguageService;
use TYPO3\CMS\Core\Page\JavaScriptModuleInstruction;
use TYPO3\CMS\Core\Page\PageRenderer;
use TYPO3\CMS\Core\Schema\Capability\TcaSchemaCapability;
use TYPO3\CMS\Core\Schema\SchemaLabelResolver;
use TYPO3\CMS\Core\Schema\TcaSchema;
use TYPO3\CMS\Core\Schema\TcaSchemaFactory;
use TYPO3\CMS\Core\Type\Bitmask\Permission;
use TYPO3\CMS\Core\Type\ContextualFeedbackSeverity;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Core\Versioning\VersionState;
/**
* The Content > Layout module.
*
* @internal This class is not part of the TYPO3 API.
*/
#[AsController]
class PageLayoutController
{
protected PageContext $pageContext;
protected ?TcaSchema $schema = null;
protected ?ModuleData $moduleData = null;
public function __construct(
protected readonly ComponentFactory $componentFactory,
protected readonly IconFactory $iconFactory,
protected readonly PageRenderer $pageRenderer,
protected readonly UriBuilder $uriBuilder,
protected readonly PageRepository $pageRepository,
protected readonly ModuleTemplateFactory $moduleTemplateFactory,
protected readonly EventDispatcherInterface $eventDispatcher,
protected readonly ModuleProvider $moduleProvider,
protected readonly BackendLayoutRenderer $backendLayoutRenderer,
protected readonly BackendLayoutView $backendLayoutView,
protected readonly TcaSchemaFactory $tcaSchemaFactory,
protected readonly ConnectionPool $connectionPool,
protected readonly LanguageSelectorBuilder $languageSelectorBuilder,
protected readonly PageLinkMessageProvider $pageLinkMessageProvider,
private readonly SchemaLabelResolver $schemaLabelResolver,
) {}
public function mainAction(ServerRequestInterface $request): ResponseInterface
{
$pageContext = $request->getAttribute('pageContext');
if (!$pageContext instanceof PageContext) {
throw new \RuntimeException('Required PageContext not available', 1731415237);
}
$this->pageContext = $pageContext;
$languageService = $this->getLanguageService();
$view = $this->moduleTemplateFactory->create($request);
if (!$this->pageContext->isAccessible() || $this->pageContext->pageId === 0) {
// In case page could not be resolved or we are on pid=0, show info to select a valid page in the tree
$view->setTitle($languageService->translate('title', 'backend.modules.layout'));
$view->assignMultiple([
'pageId' => $this->pageContext->pageId,
'siteName' => $GLOBALS['TYPO3_CONF_VARS']['SYS']['sitename'] ?? '',
]);
$view->getDocHeaderComponent()->disableAutomaticReloadButton();
return $view->renderResponse('PageLayout/PageModuleNoAccess');
}
$this->moduleData = $request->getAttribute('moduleData');
$this->schema = $this->tcaSchemaFactory->get('pages');
$pageLayoutContext = $this->createPageLayoutContext($request);
$this->updateModuleData();
$this->createViewModeSelection($view);
$this->addButtonsToButtonBar($view, $request);
$this->initializeClipboard($request);
$event = $this->eventDispatcher->dispatch(new ModifyPageLayoutContentEvent($request, $view));
$mainLayoutHtml = $this->backendLayoutRenderer->drawContent($request, $pageLayoutContext);
$primaryLanguageId = $this->pageContext->getPrimaryLanguageId();
$pageLocalizationRecord = $this->pageContext->languageInformation->getTranslationRecord($primaryLanguageId);
$this->pageRenderer->addInlineLanguageLabelFile('EXT:backend/Resources/Private/Language/locallang_layout.xlf');
$view->setTitle($languageService->translate('title', 'backend.modules.layout'), $this->pageContext->getPageTitle());
$view->getDocHeaderComponent()->setPageBreadcrumb($this->pageContext->pageRecord);
$view->assignMultiple([
'pageId' => $this->pageContext->pageId,
'localizedPageId' => $pageLocalizationRecord['uid'] ?? 0,
'pageLayoutContext' => $pageLayoutContext,
'infoBoxes' => $this->generateMessagesForCurrentPage($request),
'isPageEditable' => $this->isPageEditable($primaryLanguageId),
'localizedPageTitle' => $this->pageContext->getPageTitle($primaryLanguageId),
'eventContentHtmlTop' => $event->getHeaderContent(),
'mainContentHtml' => $mainLayoutHtml,
'eventContentHtmlBottom' => $event->getFooterContent(),
]);
return $view->renderResponse('PageLayout/PageModule');
}
protected function updateModuleData(): void
{
$backendUser = $this->getBackendUser();
if (PageViewMode::tryFrom((int)$this->moduleData->get('viewMode')) === null) {
// Invalid function, reset to default
$this->moduleData->set('viewMode', PageViewMode::LayoutView->value);
}
if ($backendUser->workspace !== 0) {
// In draft workspaces, always show all elements (including hidden)
$this->moduleData->set('showHidden', true);
}
// Store selected languages in module data for persistence
$this->moduleData->set('languages', $this->pageContext->selectedLanguageIds);
// Write module data
$backendUser->pushModuleData($this->moduleData->getModuleIdentifier(), $this->moduleData->toArray());
}
/**
* Creates the menu dropdown for switching between view modes.
*
* Available actions:
* - LayoutView: Single-language content editing (always available)
* - LanguageComparisonView: Multi-language comparison (only if translations exist)
*
* Smart Language Selection:
* When building URLs for mode switching, this method implements smart language selection:
* - Switching to layout mode with 1 translation selected → keep that translation
* - Switching to layout mode with 2+ translations selected → show default
*/
protected function createViewModeSelection(ModuleTemplate $view): void
{
$languageService = $this->getLanguageService();
$modes = [
PageViewMode::LayoutView->value => $languageService->sL(PageViewMode::LayoutView->getLabel()),
];
// Only show comparison mode if page has translations
if (!empty($this->pageContext->languageInformation->existingTranslations)) {
$modes[PageViewMode::LanguageComparisonView->value] = $languageService->sL(PageViewMode::LanguageComparisonView->getLabel());
}
// Apply TSconfig blinding
$moduleTsConfig = $this->pageContext->getModuleTsConfig('web_layout');
$blindActions = $moduleTsConfig['menu']['functions'] ?? [];
foreach ($blindActions as $key => $value) {
if (!$value && array_key_exists($key, $modes)) {
unset($modes[$key]);
}
}
// Only create menu if there are multiple actions to choose from
if (count($modes) <= 1) {
if (count($modes) === 1) {
$this->moduleData->set('viewMode', array_key_first($modes));
}
return;
}
$selectedMode = (int)$this->moduleData->get('viewMode');
if (!array_key_exists($selectedMode, $modes)) {
// Current function is not in available modes - reset to first available mode
$this->moduleData->set('viewMode', array_key_first($modes));
$selectedMode = (int)array_key_first($modes);
}
$actionMenu = $this->componentFactory->createMenu();
$actionMenu->setIdentifier('actionMenu');
$actionMenu->setLabel(
$languageService->sL('LLL:EXT:backend/Resources/Private/Language/locallang.xlf:pagelayout.moduleMenu.dropdown.label')
);
foreach ($modes as $modeValue => $label) {
$urlParams = $this->buildViewModeSwitchParams($modeValue);
$menuItem = $this->componentFactory->createMenuItem()
->setTitle($label)
->setHref((string)$this->uriBuilder->buildUriFromRoute('web_layout', $urlParams));
if ($selectedMode === $modeValue) {
$menuItem->setActive(true);
}
$actionMenu->addMenuItem($menuItem);
}
$view->getDocHeaderComponent()->getMenuRegistry()->addMenu($actionMenu);
}
/**
* Build URL parameters for switching to a specific view mode.
*
* Implements smart language selection when switching modes:
*
* FROM comparison TO layout:
* - 1 translation selected → keep that translation (user focused on it)
* - 2+ translations selected → show default language
*
* FROM layout TO comparison:
* - Keep the current language selection to preserve context
*
* @return array{id: int, viewMode: int, languages?: int[]}
*/
private function buildViewModeSwitchParams(int $targetModeValue): array
{
$params = ['id' => $this->pageContext->pageId, 'viewMode' => $targetModeValue];
$targetMode = PageViewMode::tryFrom($targetModeValue);
// Smart language selection when switching to layout mode FROM comparison mode
if ($targetMode === PageViewMode::LayoutView && $this->pageContext->hasMultipleLanguagesSelected()) {
$nonDefaultLanguages = array_filter($this->pageContext->selectedLanguageIds, static fn($id) => $id > 0);
$params['languages'] = [
count($nonDefaultLanguages) === 1
? reset($nonDefaultLanguages) // Single translation: keep it
: 0, // Multiple: show default
];
}
// When switching to comparison mode, preserve current language selection
// The comparison mode will auto-add default language if needed
if ($targetMode === PageViewMode::LanguageComparisonView) {
$params['languages'] = $this->pageContext->selectedLanguageIds;
}
return $params;
}
protected function createPageLayoutContext(
ServerRequestInterface $request
): PageLayoutContext {
$backendLayout = $this->backendLayoutView->getBackendLayoutForPage($this->pageContext->pageId);
$viewMode = count($this->pageContext->languageInformation->availableLanguages) > 1
? PageViewMode::tryFrom((int)$this->moduleData->get('viewMode')) ?? PageViewMode::LayoutView
: PageViewMode::LayoutView;
$configuration = DrawingConfiguration::create($backendLayout, $this->pageContext->pageTsConfig, $viewMode);
$configuration->setShowHidden((bool)$this->moduleData->get('showHidden'));
// Build language columns map from available languages
$languageColumns = [];
foreach ($this->pageContext->languageInformation->availableLanguages as $language) {
$languageColumns[$language->getLanguageId()] = $language->getTitle();
}
// @todo Check if this is still used at all.
$configuration->setLanguageColumns($languageColumns);
$configuration->setSelectedLanguageIds($this->pageContext->selectedLanguageIds);
return GeneralUtility::makeInstance(PageLayoutContext::class, $this->pageContext, $backendLayout, $configuration, $request);
}
/**
* Return an array of various messages for the current page record,
* such as if the page has a special doktype, that can be rendered as info boxes.
*/
protected function generateMessagesForCurrentPage(ServerRequestInterface $request): array
{
$languageService = $this->getLanguageService();
$backendUser = $this->getBackendUser();
$infoBoxes = [];
$currentDocumentType = (int)($this->pageContext->pageRecord['doktype'] ?? 0);
if ($currentDocumentType === PageRepository::DOKTYPE_SYSFOLDER && $this->moduleProvider->accessGranted('records', $backendUser)) {
$infoBoxes[] = [
'title' => $languageService->sL('LLL:EXT:backend/Resources/Private/Language/locallang_layout.xlf:goToRecordsModule'),
'message' => '<p>' . $languageService->sL('LLL:EXT:backend/Resources/Private/Language/locallang_layout.xlf:goToListModuleMessage') . '</p>'
. '<button type="button" class="btn btn-primary" data-dispatch-action="TYPO3.ModuleMenu.showModule" data-dispatch-args-list="records">'
. $languageService->sL('LLL:EXT:backend/Resources/Private/Language/locallang_layout.xlf:goToRecordsModule')
. '</button>',
'state' => ContextualFeedbackSeverity::INFO,
];
}
if ($currentDocumentType === PageRepository::DOKTYPE_SHORTCUT) {
$shortcutMode = (int)($this->pageContext->pageRecord['shortcut_mode'] ?? 0);
$targetPage = [];
$state = ContextualFeedbackSeverity::ERROR;
if ($shortcutMode || ($this->pageContext->pageRecord['shortcut'] ?? false)) {
switch ($shortcutMode) {
case PageRepository::SHORTCUT_MODE_NONE:
$targetPage = $this->getTargetPageIfVisible($this->pageRepository->getPage((int)($this->pageContext->pageRecord['shortcut'] ?? 0), true));
$message = $targetPage === [] ? $languageService->sL('LLL:EXT:backend/Resources/Private/Language/locallang_layout.xlf:pageIsMisconfiguredOrNotAccessibleInternalLinkMessage') : '';
break;
case PageRepository::SHORTCUT_MODE_FIRST_SUBPAGE:
$menuOfPages = $this->pageRepository->getMenu((int)($this->pageContext->pageRecord['uid'] ?? 0), '*', 'sorting', 'AND hidden = 0', true, true);
$targetPage = reset($menuOfPages) ?: [];
$message = $targetPage === [] ? $languageService->sL('LLL:EXT:backend/Resources/Private/Language/locallang_layout.xlf:pageIsMisconfiguredFirstSubpageMessage') : '';
break;
case PageRepository::SHORTCUT_MODE_PARENT_PAGE:
$targetPage = $this->getTargetPageIfVisible($this->pageRepository->getPage((int)($this->pageContext->pageRecord['pid'] ?? 0), true));
$message = $targetPage === [] ? $languageService->sL('LLL:EXT:backend/Resources/Private/Language/locallang_layout.xlf:pageIsMisconfiguredParentPageMessage') : '';
break;
default:
$message = htmlspecialchars($languageService->sL('LLL:EXT:backend/Resources/Private/Language/locallang_layout.xlf:pageIsMisconfiguredInternalLinkMessage'));
break;
}
$message = htmlspecialchars($message);
if ($targetPage !== []) {
$linkToPid = $this->uriBuilder->buildUriFromRoute('web_layout', ['id' => $targetPage['uid']]);
$path = BackendUtility::getRecordPath($targetPage['uid'], $backendUser->getPagePermsClause(Permission::PAGE_SHOW), 1000);
$linkedPath = '<a href="' . htmlspecialchars((string)$linkToPid) . '">' . htmlspecialchars($path) . '</a>';
$message .= sprintf(htmlspecialchars($languageService->sL('LLL:EXT:backend/Resources/Private/Language/locallang_layout.xlf:pageIsInternalLinkMessage')), $linkedPath);
$message .= ' (' . htmlspecialchars($languageService->sL($this->schemaLabelResolver->getLabelForFieldValue('pages', 'shortcut_mode', (string)$shortcutMode, $this->pageContext->pageRecord))) . ')';
$state = ContextualFeedbackSeverity::INFO;
}
} else {
$message = htmlspecialchars($languageService->sL('LLL:EXT:backend/Resources/Private/Language/locallang_layout.xlf:pageIsMisconfiguredInternalLinkMessage'));
}
$infoBoxes[] = [
'message' => $message,
'state' => $state,
];
}
if ($currentDocumentType === PageRepository::DOKTYPE_LINK) {
$primaryLanguageId = $this->pageContext->getPrimaryLanguageId();
$pageRecord = ($primaryLanguageId > 0 && ($overlayRecord = $this->pageContext->languageInformation->existingTranslations[$primaryLanguageId] ?? []) !== [])
? $overlayRecord
: $this->pageContext->pageRecord;
$infoBoxes[] = $this->pageLinkMessageProvider->generateMessagesForPageTypeLink($pageRecord, $request);
}
if ($this->pageContext->pageRecord['content_from_pid'] ?? false) {
// If content from different pid is displayed
$contentPage = BackendUtility::getRecord('pages', (int)$this->pageContext->pageRecord['content_from_pid']);
if ($contentPage === null) {
$infoBoxes[] = [
'message' => sprintf($languageService->sL('LLL:EXT:backend/Resources/Private/Language/locallang_layout.xlf:content_from_pid_invalid_title'), $this->pageContext->pageRecord['content_from_pid']),
'state' => ContextualFeedbackSeverity::ERROR,
];
} else {
$linkToPid = $this->uriBuilder->buildUriFromRoute('web_layout', ['id' => $this->pageContext->pageRecord['content_from_pid']]);
$title = BackendUtility::getRecordTitle('pages', $contentPage);
$title = BackendUtility::cropToTitleLength($title);
$link = '<a href="' . htmlspecialchars((string)$linkToPid) . '">' . htmlspecialchars($title) . ' (PID ' . (int)$this->pageContext->pageRecord['content_from_pid'] . ')</a>';
$infoBoxes[] = [
'message' => sprintf($languageService->sL('LLL:EXT:backend/Resources/Private/Language/locallang_layout.xlf:content_from_pid_title'), $link),
'state' => ContextualFeedbackSeverity::INFO,
];
}
} elseif ($this->pageContext->pageId > 0) {
$links = $this->getPageLinksWhereContentIsAlsoShownOn($this->pageContext->pageId);
if (!empty($links)) {
$infoBoxes[] = [
'message' => sprintf($languageService->sL('LLL:EXT:backend/Resources/Private/Language/locallang_layout.xlf:content_on_pid_title'), $links),
'state' => ContextualFeedbackSeverity::INFO,
];
}
}
return $infoBoxes;
}
/**
* Get all pages with links where the content of a page $pageId is also shown on.
*/
protected function getPageLinksWhereContentIsAlsoShownOn(int $pageId): string
{
$queryBuilder = $this->connectionPool->getQueryBuilderForTable('pages');
$queryBuilder->getRestrictions()->removeAll();
$queryBuilder->getRestrictions()->add(GeneralUtility::makeInstance(DeletedRestriction::class));
$queryBuilder->select('*')
->from('pages')
->where($queryBuilder->expr()->eq('content_from_pid', $queryBuilder->createNamedParameter($pageId, Connection::PARAM_INT)));
$links = [];
$rows = $queryBuilder->executeQuery()->fetchAllAssociative();
if (!empty($rows)) {
foreach ($rows as $row) {
$linkToPid = $this->uriBuilder->buildUriFromRoute('web_layout', ['id' => $row['uid']]);
$title = BackendUtility::getRecordTitle('pages', $row);
$title = BackendUtility::cropToTitleLength($title);
$link = '<a href="' . htmlspecialchars((string)$linkToPid) . '">' . htmlspecialchars($title) . ' (PID ' . (int)$row['uid'] . ')</a>';
$links[] = $link;
}
}
return implode(', ', $links);
}
protected function addButtonsToButtonBar(ModuleTemplate $view, ServerRequestInterface $request): void
{
$languageService = $this->getLanguageService();
// Close button (show only if returnUrl is set)
$returnUrl = GeneralUtility::sanitizeLocalUrl($request->getQueryParams()['returnUrl'] ?? '', $request);
if ($returnUrl) {
// use button group -1 so that close button is to the left of other buttons
$view->addButtonToButtonBar($this->componentFactory->createCloseButton($returnUrl), ButtonBar::BUTTON_POSITION_LEFT, -1);
}
// Language selector
$this->createLanguageSelector($view);
// View
if ($viewButton = $this->makeViewButton()) {
$view->addButtonToButtonBar($viewButton);
}
// Edit
if ($editButton = $this->makeEditButton($request)) {
$view->addButtonToButtonBar($editButton, ButtonBar::BUTTON_POSITION_LEFT, 3);
}
// Cache
$clearCacheButton = $this->componentFactory->createGenericButton()
->setTag('button')
->setLabel($languageService->sL('core.cache:page.label'))
->setClasses('t3js-clear-page-cache')
->setAttributes([
'type' => 'button',
'data-id' => (string)($this->pageContext->pageRecord['uid'] ?? 0),
])
->setIcon($this->iconFactory->getIcon('actions-system-cache-clear', IconSize::SMALL));
$view->addButtonToButtonBar($clearCacheButton, ButtonBar::BUTTON_POSITION_RIGHT, 1);
// View settings
if ($this->getBackendUser()->check('tables_select', 'tt_content')) {
$viewSettingsButton = $this->componentFactory->createDropDownButton()
->setLabel($languageService->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.view'))
->setIcon($this->iconFactory->getIcon('actions-cog'))
->setShowLabelText(true);
$this->pageRenderer->loadJavaScriptModule('@typo3/backend/layout-module/toggle-hidden-element.js');
$toggleHidden = $this->componentFactory->createDropDownGeneric();
$toggleHidden->setTag('typo3-backend-page-layout-toggle-hidden');
$toggleHiddenAttributes = ['count' => (string)$this->getNumberOfHiddenElements()];
if ((bool)$this->moduleData->get('showHidden')) {
$toggleHiddenAttributes['active'] = 'active';
}
$toggleHidden->setAttributes($toggleHiddenAttributes);
$viewSettingsButton->addItem($toggleHidden);
$view->addButtonToButtonBar($viewSettingsButton, ButtonBar::BUTTON_POSITION_RIGHT, 0);
}
// Shortcut
$view->getDocHeaderComponent()->setShortcutContext(
'web_layout',
sprintf(
'%s: %s [%d]',
$this->getLanguageService()->translate('short_description', 'backend.modules.layout'),
$this->pageContext->getPageTitle(),
$this->pageContext->pageId
),
[
'id' => $this->pageContext->pageId,
'showHidden' => (bool)$this->moduleData->get('showHidden'),
'viewMode' => (int)$this->moduleData->get('viewMode'),
'languages' => $this->pageContext->selectedLanguageIds,
]
);
}
protected function initializeClipboard(ServerRequestInterface $request): void
{
$clipboard = GeneralUtility::makeInstance(Clipboard::class);
$clipboard->initializeClipboard($request);
$clipboard->lockToNormal();
$clipboard->cleanCurrent();
$clipboard->endClipboard();
$elFromTable = $clipboard->elFromTable('tt_content');
if (!empty($elFromTable) && $this->isContentEditable($this->pageContext->getPrimaryLanguageId())) {
$pasteItem = (int)substr((string)key($elFromTable), 11);
$pasteRecord = BackendUtility::getRecordWSOL('tt_content', $pasteItem);
$pasteTitle = BackendUtility::getRecordTitle('tt_content', $pasteRecord);
$this->pageRenderer->getJavaScriptRenderer()->addJavaScriptModuleInstruction(
JavaScriptModuleInstruction::create('@typo3/backend/layout-module/paste.js')
->instance([
'itemOnClipboardUid' => $pasteItem,
'itemOnClipboardTitle' => $pasteTitle,
'copyMode' => $clipboard->clipData['normal']['mode'] ?? '',
])
);
}
}
/**
* View Button
*/
protected function makeViewButton(): ?ButtonInterface
{
// Do not create a "View webpage" button if
// * Multiple languages are selected
// * record is a placeholder
// * not in "Columns" view,
if (
$this->pageContext->hasMultipleLanguagesSelected()
|| VersionState::tryFrom($this->pageContext->pageRecord['t3ver_state'] ?? 0) === VersionState::DELETE_PLACEHOLDER
|| PageViewMode::tryFrom((int)$this->moduleData->get('viewMode')) !== PageViewMode::LayoutView
) {
return null;
}
$previewUriBuilder = PreviewUriBuilder::create($this->pageContext->pageRecord);
if (!$previewUriBuilder->isPreviewable()) {
return null;
}
return $this->componentFactory->createViewButton($previewUriBuilder
->withRootLine($this->pageContext->rootLine)
->withLanguage($this->pageContext->getPrimaryLanguageId())
->buildDispatcherDataAttributes() ?? []);
}
/**
* Edit Button
*/
protected function makeEditButton(ServerRequestInterface $request): ?ButtonInterface
{
$primaryLanguageId = $this->pageContext->getPrimaryLanguageId();
if (!$this->isPageEditable($primaryLanguageId)
|| PageViewMode::tryFrom((int)$this->moduleData->get('viewMode')) !== PageViewMode::LayoutView
) {
return null;
}
$pageUid = $this->pageContext->pageId;
if ($primaryLanguageId > 0 && ($overlayRecord = $this->pageContext->languageInformation->getTranslationRecord($primaryLanguageId)) !== null) {
$pageUid = $overlayRecord['uid'];
}
$editParams = [
'edit' => ['pages' => [$pageUid => 'edit']],
'module' => 'web_layout',
'returnUrl' => $request->getAttribute('normalizedParams')->getRequestUri(),
];
return $this->componentFactory->createGenericButton()
->setTag('typo3-backend-contextual-record-edit-trigger')
->setAttributes([
'url' => (string)$this->uriBuilder->buildUriFromRoute('record_edit_contextual', $editParams),
'edit-url' => (string)$this->uriBuilder->buildUriFromRoute('record_edit', $editParams),
])
->setLabel($this->getLanguageService()->sL('LLL:EXT:backend/Resources/Private/Language/locallang_layout.xlf:editPageProperties'))
->setShowLabelText(true)
->setIcon($this->iconFactory->getIcon('actions-page-open', IconSize::SMALL));
}
/**
* Creates the language selector dropdown in the module toolbar.
*/
protected function createLanguageSelector(ModuleTemplate $view): void
{
if (count($this->pageContext->languageInformation->availableLanguages) <= 1) {
return;
}
$viewMode = PageViewMode::tryFrom((int)$this->moduleData->get('viewMode')) ?? PageViewMode::LayoutView;
$isComparisonMode = $viewMode === PageViewMode::LanguageComparisonView;
$mode = $isComparisonMode ? LanguageSelectorMode::MULTI_SELECT : LanguageSelectorMode::SINGLE_SELECT;
$languageSelector = $this->languageSelectorBuilder->build(
$this->pageContext,
$mode,
fn(array $languageIds): string => (string)$this->uriBuilder->buildUriFromRoute('web_layout', [
'id' => $this->pageContext->pageId,
'viewMode' => $viewMode->value,
'languages' => $languageIds,
]),
$isComparisonMode && !empty($this->pageContext->languageInformation->existingTranslations)
);
$view->getDocHeaderComponent()->setLanguageSelector($languageSelector);
}
/**
* Returns the number of hidden elements (including those hidden by start/end times)
* on the current page (for the current site language)
*/
protected function getNumberOfHiddenElements(): int
{
$isComparisonView = (PageViewMode::tryFrom((int)$this->moduleData->get('viewMode')) ?? PageViewMode::LayoutView) === PageViewMode::LanguageComparisonView;
$andWhere = [];
$queryBuilder = $this->connectionPool->getQueryBuilderForTable('tt_content');
$queryBuilder->getRestrictions()
->removeAll()
->add(GeneralUtility::makeInstance(DeletedRestriction::class))
->add(GeneralUtility::makeInstance(WorkspaceRestriction::class, $this->getBackendUser()->workspace));
$queryBuilder
->count('uid')
->from('tt_content')
->where(
$queryBuilder->expr()->eq(
'pid',
$queryBuilder->createNamedParameter($this->pageContext->pageId, Connection::PARAM_INT)
)
);
$languageField = $this->schema->getCapability(TcaSchemaCapability::Language)->getLanguageField()->getName();
// Build list of language IDs to include: always include -1 (all languages) and all selected languages
$languageIds = [-1];
foreach ($this->pageContext->selectedLanguageIds as $languageId) {
$languageIds[] = $languageId;
// In comparison mode, also include default language (0) if not already selected
if ($isComparisonView && $languageId > 0 && !$this->pageContext->isDefaultLanguageSelected()) {
$languageIds[] = 0;
}
}
$languageIds = array_unique($languageIds);
$queryBuilder->andWhere(
$queryBuilder->expr()->in(
$languageField,
$queryBuilder->createNamedParameter($languageIds, Connection::PARAM_INT_ARRAY)
)
);
if ($this->schema->hasCapability(TcaSchemaCapability::RestrictionDisabledField)) {
$andWhere[] = $queryBuilder->expr()->neq(
$this->schema->getCapability(TcaSchemaCapability::RestrictionDisabledField)->getFieldName(),
$queryBuilder->createNamedParameter(0, Connection::PARAM_INT)
);
}
if ($this->schema->hasCapability(TcaSchemaCapability::RestrictionStartTime)) {
$starttimeField = $this->schema->getCapability(TcaSchemaCapability::RestrictionStartTime)->getFieldName();
$andWhere[] = $queryBuilder->expr()->and(
$queryBuilder->expr()->neq(
$starttimeField,
$queryBuilder->createNamedParameter(0, Connection::PARAM_INT)
),
$queryBuilder->expr()->gt(
$starttimeField,
$queryBuilder->createNamedParameter($GLOBALS['SIM_ACCESS_TIME'], Connection::PARAM_INT)
)
);
}
if ($this->schema->hasCapability(TcaSchemaCapability::RestrictionEndTime)) {
$endtimeField = $this->schema->getCapability(TcaSchemaCapability::RestrictionEndTime)->getFieldName();
$andWhere[] = $queryBuilder->expr()->and(
$queryBuilder->expr()->neq(
$endtimeField,
$queryBuilder->createNamedParameter(0, Connection::PARAM_INT)
),
$queryBuilder->expr()->lte(
$endtimeField,
$queryBuilder->createNamedParameter($GLOBALS['SIM_ACCESS_TIME'], Connection::PARAM_INT)
)
);
}
if ($andWhere !== []) {
$queryBuilder->andWhere(
$queryBuilder->expr()->or(...$andWhere)
);
}
$count = $queryBuilder
->executeQuery()
->fetchOne();
return (int)$count;
}
/**
* Check if page can be edited by current user.
*/
protected function isPageEditable(int $languageId): bool
{
if (empty($this->pageContext->pageRecord)) {
return false;
}
if ($this->schema->hasCapability(TcaSchemaCapability::AccessReadOnly)) {
return false;
}
$backendUser = $this->getBackendUser();
if ($backendUser->isAdmin()) {
return true;
}
if ($this->schema->hasCapability(TcaSchemaCapability::AccessAdminOnly)) {
return false;
}
$isEditLocked = false;
if ($this->schema->hasCapability(TcaSchemaCapability::EditLock)) {
$isEditLocked = $this->pageContext->pageRecord[$this->schema->getCapability(TcaSchemaCapability::EditLock)->getFieldName()] ?? false;
}
if ($isEditLocked) {
return false;
}
return $backendUser->doesUserHaveAccess($this->pageContext->pageRecord, Permission::PAGE_EDIT)
&& $backendUser->checkLanguageAccess($languageId)
&& $backendUser->check('tables_modify', 'pages');
}
/**
* Check if content can be edited by current user
*/
protected function isContentEditable(int $languageId): bool
{
if ($this->getBackendUser()->isAdmin()) {
return true;
}
$isEditLocked = false;
if ($this->schema->hasCapability(TcaSchemaCapability::EditLock)) {
$isEditLocked = $this->pageContext->pageRecord[$this->schema->getCapability(TcaSchemaCapability::EditLock)->getFieldName()] ?? false;
}
if ($isEditLocked) {
return false;
}
return $this->getBackendUser()->doesUserHaveAccess($this->pageContext->pageRecord, Permission::CONTENT_EDIT)
&& $this->getBackendUser()->check('tables_modify', 'tt_content')
&& $this->getBackendUser()->checkLanguageAccess($languageId);
}
/**
* Returns the target page if visible
*/
protected function getTargetPageIfVisible(array $targetPage): array
{
$fieldName = $this->schema->getCapability(TcaSchemaCapability::RestrictionDisabledField)->getFieldName();
return !($targetPage[$fieldName] ?? false) ? $targetPage : [];
}
protected function getLanguageService(): LanguageService
{
return $GLOBALS['LANG'];
}
protected function getBackendUser(): BackendUserAuthentication
{
return $GLOBALS['BE_USER'];
}
}
@@ -0,0 +1,257 @@
<?php
declare(strict_types=1);
/*
* 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 TYPO3\CMS\Backend\Controller\PageTsConfig;
use Psr\Container\ContainerInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Backend\Attribute\AsController;
use TYPO3\CMS\Backend\Module\ModuleData;
use TYPO3\CMS\Backend\Routing\UriBuilder;
use TYPO3\CMS\Backend\Template\ModuleTemplateFactory;
use TYPO3\CMS\Backend\Utility\BackendUtility;
use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
use TYPO3\CMS\Core\Http\RedirectResponse;
use TYPO3\CMS\Core\Localization\LanguageService;
use TYPO3\CMS\Core\Site\Entity\Site;
use TYPO3\CMS\Core\TypoScript\AST\Traverser\AstTraverser;
use TYPO3\CMS\Core\TypoScript\AST\Visitor\AstSortChildrenVisitor;
use TYPO3\CMS\Core\TypoScript\IncludeTree\IncludeNode\RootInclude;
use TYPO3\CMS\Core\TypoScript\IncludeTree\IncludeNode\SiteInclude;
use TYPO3\CMS\Core\TypoScript\IncludeTree\IncludeNode\TsConfigInclude;
use TYPO3\CMS\Core\TypoScript\IncludeTree\Traverser\ConditionVerdictAwareIncludeTreeTraverser;
use TYPO3\CMS\Core\TypoScript\IncludeTree\Traverser\IncludeTreeTraverser;
use TYPO3\CMS\Core\TypoScript\IncludeTree\TsConfigTreeBuilder;
use TYPO3\CMS\Core\TypoScript\IncludeTree\Visitor\IncludeTreeAstBuilderVisitor;
use TYPO3\CMS\Core\TypoScript\IncludeTree\Visitor\IncludeTreeCommentAwareAstBuilderVisitor;
use TYPO3\CMS\Core\TypoScript\IncludeTree\Visitor\IncludeTreeConditionAggregatorVisitor;
use TYPO3\CMS\Core\TypoScript\IncludeTree\Visitor\IncludeTreeConditionEnforcerVisitor;
use TYPO3\CMS\Core\TypoScript\IncludeTree\Visitor\IncludeTreeSetupConditionConstantSubstitutionVisitor;
use TYPO3\CMS\Core\TypoScript\Tokenizer\LosslessTokenizer;
/**
* Page TSconfig > Active page TSconfig
*
* @internal This class is a specific Backend controller implementation and is not part of the TYPO3's Core API.
*/
#[AsController]
final readonly class PageTsConfigActiveController
{
public function __construct(
private ContainerInterface $container,
private UriBuilder $uriBuilder,
private ModuleTemplateFactory $moduleTemplateFactory,
private TsConfigTreeBuilder $tsConfigTreeBuilder,
) {}
public function handleRequest(ServerRequestInterface $request): ResponseInterface
{
$backendUser = $this->getBackendUser();
$languageService = $this->getLanguageService();
$queryParams = $request->getQueryParams();
$parsedBody = $request->getParsedBody();
$currentModule = $request->getAttribute('module');
$currentModuleIdentifier = $currentModule->getIdentifier();
$moduleData = $request->getAttribute('moduleData');
$pageUid = (int)($parsedBody['id'] ?? $queryParams['id'] ?? 0);
$pageRecord = BackendUtility::readPageAccess($pageUid, '1=1') ?: [];
if (empty($pageRecord)) {
// Redirect to overview if page could not be determined.
// Edge case if page has been removed meanwhile.
BackendUtility::setUpdateSignal('updatePageTree');
return new RedirectResponse($this->uriBuilder->buildUriFromRoute('pagetsconfig_pages'));
}
// Force boolean toggles to bool and init further get/post vars
if ($moduleData->clean('displayConstantSubstitutions', [true, false])) {
$backendUser->pushModuleData($currentModuleIdentifier, $moduleData->toArray());
}
$displayConstantSubstitutions = $moduleData->get('displayConstantSubstitutions');
if ($moduleData->clean('displayComments', [true, false])) {
$backendUser->pushModuleData($currentModuleIdentifier, $moduleData->toArray());
}
$displayComments = $moduleData->get('displayComments');
if ($moduleData->clean('sortAlphabetically', [true, false])) {
$backendUser->pushModuleData($currentModuleIdentifier, $moduleData->toArray());
}
$sortAlphabetically = $moduleData->get('sortAlphabetically');
// Prepare site constants if any
$site = $request->getAttribute('site');
$siteSettingsAst = null;
$siteSettingsFlat = [];
if ($site instanceof Site && !$site->getSettings()->isEmpty()) {
$siteSettings = $site->getSettings()->getAllFlat();
$siteConstants = '';
foreach ($siteSettings as $nodeIdentifier => $value) {
$siteConstants .= $nodeIdentifier . ' = ' . $value . LF;
}
$siteSettingsNode = new SiteInclude();
$siteSettingsNode->setName('Site constants settings of site "' . $site->getIdentifier() . '"');
$siteSettingsNode->setLineStream((new LosslessTokenizer())->tokenize($siteConstants));
$siteSettingsTreeRoot = new RootInclude();
$siteSettingsTreeRoot->addChild($siteSettingsNode);
$astBuilderVisitor = $this->container->get(IncludeTreeAstBuilderVisitor::class);
$includeTreeTraverser = new IncludeTreeTraverser();
$includeTreeTraverser->traverse($siteSettingsTreeRoot, [$astBuilderVisitor]);
$siteSettingsAst = $astBuilderVisitor->getAst();
// Trigger unique identifier creation for entire tree
$siteSettingsAst->setIdentifier('pageTsConfig-siteSettingsAst');
$siteSettingsFlat = $siteSettingsAst->flatten();
if ($sortAlphabetically) {
// Traverse AST to sort if needed
$astTraverser = new AstTraverser();
$astTraverser->traverse($siteSettingsAst, [new AstSortChildrenVisitor()]);
}
}
// Base page TSconfig tree
$rootLine = BackendUtility::BEgetRootLine($pageUid, '', true);
ksort($rootLine);
$pagesTsConfigTree = $this->tsConfigTreeBuilder->getPagesTsConfigTree($rootLine, new LosslessTokenizer());
// Overload tree with user TSconfig if any
$userTsConfig = $backendUser->getUserTsConfig();
if ($userTsConfig === null) {
throw new \RuntimeException('User TSconfig not initialized', 1674609098);
}
$userTsConfigAst = $userTsConfig->getUserTsConfigTree();
$userTsConfigPageOverrides = '';
// @todo: Ugly, similar in PageTsConfigFactory.
$userTsConfigFlat = $userTsConfigAst->flatten();
foreach ($userTsConfigFlat as $userTsConfigIdentifier => $userTsConfigValue) {
if (str_starts_with($userTsConfigIdentifier, 'page.')) {
$userTsConfigPageOverrides .= substr($userTsConfigIdentifier, 5) . ' = ' . $userTsConfigValue . chr(10);
}
}
if (!empty($userTsConfigPageOverrides)) {
$includeNode = new TsConfigInclude();
$includeNode->setName('pageTsConfig-overrides-by-userTsConfig');
$includeNode->setLineStream((new LosslessTokenizer())->tokenize($userTsConfigPageOverrides));
$pagesTsConfigTree->addChild($includeNode);
}
// Set enabled conditions in page TSconfig include tree and let it handle constant substitutions in page TSconfig conditions.
$pageTsConfigConditions = $this->handleToggledPageTsConfigConditions($pagesTsConfigTree, $moduleData, $parsedBody, $siteSettingsFlat);
$conditionEnforcerVisitor = new IncludeTreeConditionEnforcerVisitor();
$conditionEnforcerVisitor->setEnabledConditions(array_column(array_filter($pageTsConfigConditions, static fn(array $condition): bool => (bool)$condition['active']), 'value'));
$treeTraverser = new IncludeTreeTraverser();
$treeTraverser->traverse($pagesTsConfigTree, [$conditionEnforcerVisitor]);
// Create AST with constants from site and conditions
$includeTreeTraverser = new ConditionVerdictAwareIncludeTreeTraverser();
$astBuilderVisitor = $this->container->get(IncludeTreeCommentAwareAstBuilderVisitor::class);
$astBuilderVisitor->setFlatConstants($siteSettingsFlat);
$includeTreeTraverser->traverse($pagesTsConfigTree, [$astBuilderVisitor]);
$pageTsConfigAst = $astBuilderVisitor->getAst();
// Trigger unique identifier creation for entire tree
$pageTsConfigAst->setIdentifier('pageTsConfig');
if ($sortAlphabetically) {
// Traverse AST to sort if needed
$astTraverser = new AstTraverser();
$astTraverser->traverse($pageTsConfigAst, [new AstSortChildrenVisitor()]);
}
$view = $this->moduleTemplateFactory->create($request);
$view->setTitle($languageService->sL($currentModule->getTitle()), $pageRecord['title'] ?? $GLOBALS['TYPO3_CONF_VARS']['SYS']['sitename'] ?? '');
$view->getDocHeaderComponent()->setPageBreadcrumb($pageRecord);
$shortcutTitle = sprintf(
'%s: %s [%d]',
$languageService->translate('title', 'backend.modules.pagetsconfig_active'),
BackendUtility::getRecordTitle('pages', $pageRecord),
$pageUid
);
$view->getDocHeaderComponent()->setShortcutContext(
$currentModuleIdentifier,
$shortcutTitle,
['id' => $pageUid]
);
$view->makeDocHeaderModuleMenu(['id' => $pageUid]);
$view->assignMultiple([
'pageUid' => $pageUid,
'pageTitle' => $pageRecord['title'] ?? $GLOBALS['TYPO3_CONF_VARS']['SYS']['sitename'] ?? '',
'displayConstantSubstitutions' => $displayConstantSubstitutions,
'displayComments' => $displayComments,
'sortAlphabetically' => $sortAlphabetically,
'siteSettingsAst' => $siteSettingsAst,
'pageTsConfigAst' => $pageTsConfigAst,
'pageTsConfigConditions' => $pageTsConfigConditions,
'pageTsConfigConditionsActiveCount' => count(array_filter($pageTsConfigConditions, static fn(array $condition): bool => (bool)$condition['active'])),
]);
return $view->renderResponse('PageTsConfig/Active');
}
/**
* Align module data active page TSconfig conditions with toggled conditions from POST,
* write updated active conditions to user's module data if needed and
* prepare a list of active conditions for view.
*/
private function handleToggledPageTsConfigConditions(RootInclude $pageTsConfigTree, ModuleData $moduleData, ?array $parsedBody, array $flattenedConstants): array
{
$treeTraverser = new IncludeTreeTraverser();
$treeTraverserVisitors = [];
$setupConditionConstantSubstitutionVisitor = new IncludeTreeSetupConditionConstantSubstitutionVisitor();
$setupConditionConstantSubstitutionVisitor->setFlattenedConstants($flattenedConstants);
$treeTraverserVisitors[] = $setupConditionConstantSubstitutionVisitor;
$conditionAggregatorVisitor = new IncludeTreeConditionAggregatorVisitor();
$treeTraverserVisitors[] = $conditionAggregatorVisitor;
$treeTraverser->traverse($pageTsConfigTree, $treeTraverserVisitors);
$pageTsConfigConditions = $conditionAggregatorVisitor->getConditions();
$conditionsFromPost = $parsedBody['pageTsConfigConditions'] ?? [];
$conditionsFromModuleData = array_flip((array)$moduleData->get('pageTsConfigConditions'));
$conditions = [];
foreach ($pageTsConfigConditions as $condition) {
$conditionHash = hash('xxh3', $condition['value']);
$conditionActive = array_key_exists($conditionHash, $conditionsFromModuleData);
// Note we're not feeding the post values directly to module data, but filter
// them through available conditions to prevent polluting module data with
// manipulated post values.
if (($conditionsFromPost[$conditionHash] ?? null) === '0') {
unset($conditionsFromModuleData[$conditionHash]);
$conditionActive = false;
} elseif (($conditionsFromPost[$conditionHash] ?? null) === '1') {
$conditionsFromModuleData[$conditionHash] = true;
$conditionActive = true;
}
$conditions[] = [
'value' => $condition['value'],
'originalValue' => $condition['originalValue'],
'hash' => $conditionHash,
'active' => $conditionActive,
];
}
if ($conditionsFromPost) {
$moduleData->set('pageTsConfigConditions', array_keys($conditionsFromModuleData));
$this->getBackendUser()->pushModuleData($moduleData->getModuleIdentifier(), $moduleData->toArray());
}
return $conditions;
}
private function getLanguageService(): LanguageService
{
return $GLOBALS['LANG'];
}
private function getBackendUser(): BackendUserAuthentication
{
return $GLOBALS['BE_USER'];
}
}
@@ -0,0 +1,300 @@
<?php
declare(strict_types=1);
/*
* 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 TYPO3\CMS\Backend\Controller\PageTsConfig;
use Psr\Http\Message\ResponseFactoryInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\StreamFactoryInterface;
use TYPO3\CMS\Backend\Attribute\AsController;
use TYPO3\CMS\Backend\Routing\UriBuilder;
use TYPO3\CMS\Backend\Template\ModuleTemplateFactory;
use TYPO3\CMS\Backend\Utility\BackendUtility;
use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
use TYPO3\CMS\Core\Http\RedirectResponse;
use TYPO3\CMS\Core\Localization\LanguageService;
use TYPO3\CMS\Core\Site\Entity\Site;
use TYPO3\CMS\Core\TypoScript\IncludeTree\IncludeNode\RootInclude;
use TYPO3\CMS\Core\TypoScript\IncludeTree\IncludeNode\SiteInclude;
use TYPO3\CMS\Core\TypoScript\IncludeTree\IncludeNode\TsConfigInclude;
use TYPO3\CMS\Core\TypoScript\IncludeTree\Traverser\IncludeTreeTraverser;
use TYPO3\CMS\Core\TypoScript\IncludeTree\TsConfigTreeBuilder;
use TYPO3\CMS\Core\TypoScript\IncludeTree\Visitor\IncludeTreeNodeFinderVisitor;
use TYPO3\CMS\Core\TypoScript\IncludeTree\Visitor\IncludeTreeSourceAggregatorVisitor;
use TYPO3\CMS\Core\TypoScript\IncludeTree\Visitor\IncludeTreeSyntaxScannerVisitor;
use TYPO3\CMS\Core\TypoScript\Tokenizer\LosslessTokenizer;
/**
* Page TSconfig > Included page TSconfig
*
* @internal This class is a specific Backend controller implementation and is not part of the TYPO3's Core API.
*/
#[AsController]
final readonly class PageTsConfigIncludesController
{
public function __construct(
private UriBuilder $uriBuilder,
private ModuleTemplateFactory $moduleTemplateFactory,
private TsConfigTreeBuilder $tsConfigTreeBuilder,
private ResponseFactoryInterface $responseFactory,
private StreamFactoryInterface $streamFactory,
) {}
public function indexAction(ServerRequestInterface $request): ResponseInterface
{
$backendUser = $this->getBackendUser();
$languageService = $this->getLanguageService();
$queryParams = $request->getQueryParams();
$currentModule = $request->getAttribute('module');
$currentModuleIdentifier = $currentModule->getIdentifier();
$pageUid = (int)($queryParams['id'] ?? 0);
$pageRecord = BackendUtility::readPageAccess($pageUid, '1=1') ?: [];
if (empty($pageRecord)) {
// Redirect to records overview if page could not be determined.
// Edge case if page has been removed meanwhile.
BackendUtility::setUpdateSignal('updatePageTree');
return new RedirectResponse($this->uriBuilder->buildUriFromRoute('pagetsconfig_pages'));
}
// Prepare site constants if any
$site = $request->getAttribute('site');
$siteSettingsTree = new RootInclude();
if ($site instanceof Site && !$site->getSettings()->isEmpty()) {
$siteSettings = $site->getSettings()->getAllFlat();
$siteConstants = '';
foreach ($siteSettings as $nodeIdentifier => $value) {
$siteConstants .= $nodeIdentifier . ' = ' . $value . LF;
}
$siteSettingsNode = new SiteInclude();
$siteSettingsNode->setName('Site constants settings of site "' . $site->getIdentifier() . '"');
$siteSettingsNode->setLineStream((new LosslessTokenizer())->tokenize($siteConstants));
$siteSettingsTree->addChild($siteSettingsNode);
$siteSettingsTree->setIdentifier('pageTsConfig-siteSettingsTree');
}
// Base page TSconfig tree
$rootLine = BackendUtility::BEgetRootLine($pageUid, '', true);
ksort($rootLine);
$pageTsConfigTree = $this->tsConfigTreeBuilder->getPagesTsConfigTree($rootLine, new LosslessTokenizer());
// Overload tree with user TSconfig if any
$userTsConfig = $backendUser->getUserTsConfig();
if ($userTsConfig === null) {
throw new \RuntimeException('User TSconfig not initialized', 1675535278);
}
$userTsConfigAst = $userTsConfig->getUserTsConfigTree();
$userTsConfigPageOverrides = '';
// @todo: Ugly, similar in PageTsConfigFactory.
$userTsConfigFlat = $userTsConfigAst->flatten();
foreach ($userTsConfigFlat as $userTsConfigIdentifier => $userTsConfigValue) {
if (str_starts_with($userTsConfigIdentifier, 'page.')) {
$userTsConfigPageOverrides .= substr($userTsConfigIdentifier, 5) . ' = ' . $userTsConfigValue . chr(10);
}
}
if (!empty($userTsConfigPageOverrides)) {
$includeNode = new TsConfigInclude();
$includeNode->setName('pageTsConfig-overrides-by-userTsConfig');
$includeNode->setLineStream((new LosslessTokenizer())->tokenize($userTsConfigPageOverrides));
$pageTsConfigTree->addChild($includeNode);
}
$pageTsConfigTree->setIdentifier('pageTsConfig-pageTsConfigTree');
$treeTraverser = new IncludeTreeTraverser();
$treeTraverserVisitors = [];
$syntaxScannerVisitor = new IncludeTreeSyntaxScannerVisitor();
$treeTraverserVisitors[] = $syntaxScannerVisitor;
$treeTraverser->traverse($pageTsConfigTree, $treeTraverserVisitors);
$view = $this->moduleTemplateFactory->create($request);
$view->setTitle($languageService->sL($currentModule->getTitle()), $pageRecord['title'] ?? $GLOBALS['TYPO3_CONF_VARS']['SYS']['sitename'] ?? '');
$view->getDocHeaderComponent()->setPageBreadcrumb($pageRecord);
$shortcutTitle = sprintf(
'%s: %s [%d]',
$languageService->translate('title', 'backend.modules.pagetsconfig_includes'),
BackendUtility::getRecordTitle('pages', $pageRecord),
$pageUid
);
$view->getDocHeaderComponent()->setShortcutContext(
$currentModuleIdentifier,
$shortcutTitle,
['id' => $pageUid]
);
$view->makeDocHeaderModuleMenu(['id' => $pageUid]);
$view->assignMultiple([
'pageUid' => $pageUid,
'pageTitle' => $pageRecord['title'] ?? $GLOBALS['TYPO3_CONF_VARS']['SYS']['sitename'] ?? '',
'siteSettingsTree' => $siteSettingsTree,
'pageTsConfigTree' => $pageTsConfigTree,
'syntaxErrors' => $syntaxScannerVisitor->getErrors(),
'syntaxErrorCount' => count($syntaxScannerVisitor->getErrors()),
]);
return $view->renderResponse('PageTsConfig/Includes');
}
public function sourceAction(ServerRequestInterface $request): ResponseInterface
{
$backendUser = $this->getBackendUser();
$queryParams = $request->getQueryParams();
$pageUid = (int)($queryParams['id'] ?? 0);
$type = $queryParams['includeType'] ?? null;
$includeIdentifier = $queryParams['identifier'] ?? null;
if ($pageUid === 0 || $includeIdentifier === null || !in_array($type, ['constants', 'setup'], true)) {
return $this->responseFactory->createResponse(400);
}
if ($type === 'constants') {
// Prepare site constants if any
$site = $request->getAttribute('site');
$includeTree = new RootInclude();
if ($site instanceof Site && !$site->getSettings()->isEmpty()) {
$siteSettings = $site->getSettings()->getAllFlat();
$siteConstants = '';
foreach ($siteSettings as $nodeIdentifier => $value) {
$siteConstants .= $nodeIdentifier . ' = ' . $value . LF;
}
$siteSettingsNode = new SiteInclude();
$siteSettingsNode->setName('Site constants settings of site "' . $site->getIdentifier() . '"');
$siteSettingsNode->setLineStream((new LosslessTokenizer())->tokenize($siteConstants));
$includeTree->addChild($siteSettingsNode);
$includeTree->setIdentifier('pageTsConfig-siteSettingsTree');
}
} else {
// Base page TSconfig tree
$rootLine = BackendUtility::BEgetRootLine($pageUid, '', true);
ksort($rootLine);
$includeTree = $this->tsConfigTreeBuilder->getPagesTsConfigTree($rootLine, new LosslessTokenizer());
// Overload tree with user TSconfig if any
$userTsConfig = $backendUser->getUserTsConfig();
if ($userTsConfig === null) {
throw new \RuntimeException('UserTsConfig not initialized', 1675535279);
}
$userTsConfigAst = $userTsConfig->getUserTsConfigTree();
$userTsConfigPageOverrides = '';
// @todo: Ugly, similar in PageTsConfigFactory.
$userTsConfigFlat = $userTsConfigAst->flatten();
foreach ($userTsConfigFlat as $userTsConfigIdentifier => $userTsConfigValue) {
if (str_starts_with($userTsConfigIdentifier, 'page.')) {
$userTsConfigPageOverrides .= substr($userTsConfigIdentifier, 5) . ' = ' . $userTsConfigValue . chr(10);
}
}
if (!empty($userTsConfigPageOverrides)) {
$includeNode = new TsConfigInclude();
$includeNode->setName('pageTsConfig-overrides-by-userTsConfig');
$includeNode->setLineStream((new LosslessTokenizer())->tokenize($userTsConfigPageOverrides));
$includeTree->addChild($includeNode);
}
$includeTree->setIdentifier('pageTsConfig-pageTsConfigTree');
}
$nodeFinderVisitor = new IncludeTreeNodeFinderVisitor();
$nodeFinderVisitor->setNodeIdentifier($includeIdentifier);
$treeTraverser = new IncludeTreeTraverser();
$treeTraverser->traverse($includeTree, [$nodeFinderVisitor]);
$lineStream = $nodeFinderVisitor->getFoundNode()?->getLineStream();
if ($lineStream === null) {
return $this->responseFactory->createResponse(400);
}
return $this->responseFactory
->createResponse()
->withHeader('Content-Type', 'text/plain')
->withBody($this->streamFactory->createStream((string)$lineStream));
}
public function sourceWithIncludesAction(ServerRequestInterface $request): ResponseInterface
{
$backendUser = $this->getBackendUser();
$queryParams = $request->getQueryParams();
$pageUid = (int)($queryParams['id'] ?? 0);
$type = $queryParams['includeType'] ?? null;
$includeIdentifier = $queryParams['identifier'] ?? null;
if ($pageUid === 0 || $includeIdentifier === null || !in_array($type, ['constants', 'setup'], true)) {
return $this->responseFactory->createResponse(400);
}
if ($type === 'constants') {
// Prepare site constants if any
$site = $request->getAttribute('site');
$includeTree = new RootInclude();
if ($site instanceof Site && !$site->getSettings()->isEmpty()) {
$siteSettings = $site->getSettings()->getAllFlat();
$siteConstants = '';
foreach ($siteSettings as $nodeIdentifier => $value) {
$siteConstants .= $nodeIdentifier . ' = ' . $value . LF;
}
$siteSettingsNode = new SiteInclude();
$siteSettingsNode->setName('Site constants settings of site "' . $site->getIdentifier() . '"');
$siteSettingsNode->setLineStream((new LosslessTokenizer())->tokenize($siteConstants));
$includeTree->addChild($siteSettingsNode);
$includeTree->setIdentifier('pageTsConfig-siteSettingsTree');
}
} else {
// Base page TSconfig tree
$rootLine = BackendUtility::BEgetRootLine($pageUid, '', true);
ksort($rootLine);
$includeTree = $this->tsConfigTreeBuilder->getPagesTsConfigTree($rootLine, new LosslessTokenizer());
// Overload tree with user TSconfig if any
$userTsConfig = $backendUser->getUserTsConfig();
if ($userTsConfig === null) {
throw new \RuntimeException('UserTsConfig not initialized', 1675535280);
}
$userTsConfigAst = $userTsConfig->getUserTsConfigTree();
$userTsConfigPageOverrides = '';
// @todo: Ugly, similar in PageTsConfigFactory.
$userTsConfigFlat = $userTsConfigAst->flatten();
foreach ($userTsConfigFlat as $userTsConfigIdentifier => $userTsConfigValue) {
if (str_starts_with($userTsConfigIdentifier, 'page.')) {
$userTsConfigPageOverrides .= substr($userTsConfigIdentifier, 5) . ' = ' . $userTsConfigValue . chr(10);
}
}
if (!empty($userTsConfigPageOverrides)) {
$includeNode = new TsConfigInclude();
$includeNode->setName('pageTsConfig-overrides-by-userTsConfig');
$includeNode->setLineStream((new LosslessTokenizer())->tokenize($userTsConfigPageOverrides));
$includeTree->addChild($includeNode);
}
$includeTree->setIdentifier('pageTsConfig-pageTsConfigTree');
}
$sourceAggregatorVisitor = new IncludeTreeSourceAggregatorVisitor();
$sourceAggregatorVisitor->setStartNodeIdentifier($includeIdentifier);
$treeTraverser = new IncludeTreeTraverser();
$treeTraverser->traverse($includeTree, [$sourceAggregatorVisitor]);
$source = $sourceAggregatorVisitor->getSource();
return $this->responseFactory
->createResponse()
->withHeader('Content-Type', 'text/plain')
->withBody($this->streamFactory->createStream($source));
}
private function getLanguageService(): LanguageService
{
return $GLOBALS['LANG'];
}
private function getBackendUser(): BackendUserAuthentication
{
return $GLOBALS['BE_USER'];
}
}
@@ -0,0 +1,212 @@
<?php
declare(strict_types=1);
/*
* 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 TYPO3\CMS\Backend\Controller\PageTsConfig;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Backend\Attribute\AsController;
use TYPO3\CMS\Backend\Module\ModuleInterface;
use TYPO3\CMS\Backend\Routing\UriBuilder;
use TYPO3\CMS\Backend\Template\ModuleTemplateFactory;
use TYPO3\CMS\Backend\Utility\BackendUtility;
use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
use TYPO3\CMS\Core\Database\Connection;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Database\Query\Restriction\DeletedRestriction;
use TYPO3\CMS\Core\Database\Query\Restriction\WorkspaceRestriction;
use TYPO3\CMS\Core\Imaging\IconFactory;
use TYPO3\CMS\Core\Imaging\IconSize;
use TYPO3\CMS\Core\Localization\LanguageService;
use TYPO3\CMS\Core\Type\Bitmask\Permission;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Core\Utility\MathUtility;
/**
* Page TSconfig > Page TSconfig Configuration
*
* @internal This class is a specific Backend controller implementation and is not part of the TYPO3's Core API.
*/
#[AsController]
final readonly class PageTsConfigRecordsOverviewController
{
public function __construct(
private IconFactory $iconFactory,
private UriBuilder $uriBuilder,
private ModuleTemplateFactory $moduleTemplateFactory,
) {}
public function handleRequest(ServerRequestInterface $request): ResponseInterface
{
$backendUser = $this->getBackendUser();
$currentModule = $request->getAttribute('module');
$currentModuleIdentifier = $currentModule->getIdentifier();
$pageId = (int)($request->getQueryParams()['id'] ?? 0);
$pageRecord = BackendUtility::readPageAccess($pageId, $backendUser->getPagePermsClause(Permission::PAGE_SHOW)) ?: [];
$moduleData = $request->getAttribute('moduleData');
if ($moduleData->cleanUp([])) {
$backendUser->pushModuleData($currentModuleIdentifier, $moduleData->toArray());
}
$view = $this->moduleTemplateFactory->create($request);
$view->setTitle(
$this->getLanguageService()->sL($currentModule->getTitle()),
$pageId !== 0 && isset($pageRecord['title']) ? $pageRecord['title'] : ''
);
// The page will show only if there is a valid page and if this page may be viewed by the user.
if ($pageRecord !== []) {
$view->getDocHeaderComponent()->setPageBreadcrumb($pageRecord);
}
$accessContent = false;
if (($pageId && $pageRecord !== []) || ($backendUser->isAdmin() && !$pageId)) {
$accessContent = true;
if (!$pageId && $backendUser->isAdmin()) {
$pageRecord = ['title' => '[root-level]', 'uid' => 0, 'pid' => 0];
}
$view->assign('id', $pageId);
// Setting up the buttons and the module menu for the doc header
$view->getDocHeaderComponent()->setShortcutContext(
$currentModule->getIdentifier(),
$this->getLanguageService()->sL($currentModule->getTitle()),
['id' => $pageId]
);
}
$view->assign('accessContent', $accessContent);
$pagesUsingTSConfig = $this->getOverviewOfPagesUsingTSConfig($currentModule);
if (count($pagesUsingTSConfig) > 0) {
$view->assign('overviewOfPagesUsingTSConfig', $pagesUsingTSConfig);
}
$view->makeDocHeaderModuleMenu(['id' => $pageId]);
return $view->renderResponse('PageTsConfig/RecordsOverview');
}
/**
* Renders table rows of all pages containing TSConfig together with its rootline
*/
private function getOverviewOfPagesUsingTSConfig(ModuleInterface $currentModule): array
{
$queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('pages');
$queryBuilder->getRestrictions()
->removeAll()
->add(GeneralUtility::makeInstance(DeletedRestriction::class))
->add(GeneralUtility::makeInstance(WorkspaceRestriction::class, 0));
$res = $queryBuilder
->select('uid', 'TSconfig')
->from('pages')
->where(
$queryBuilder->expr()->neq(
'TSconfig',
$queryBuilder->createNamedParameter('')
),
$queryBuilder->expr()->eq(
'sys_language_uid',
$queryBuilder->createNamedParameter(0, Connection::PARAM_INT)
)
)
->executeQuery();
$pageArray = [];
while ($row = $res->fetchAssociative()) {
$this->setInPageArray($pageArray, BackendUtility::BEgetRootLine($row['uid'], 'AND 1=1'), $row);
}
return $this->getList($currentModule, $pageArray);
}
/**
* Builds a multidimensional array that reflects the page hierarchy.
*/
private function setInPageArray(array &$hierarchicArray, array $rootlineArray, array $row): void
{
ksort($rootlineArray);
if (!($rootlineArray[0]['uid'] ?? false)) {
array_shift($rootlineArray);
}
$currentElement = current($rootlineArray);
$hierarchicArray[$currentElement['uid']] = htmlspecialchars($currentElement['title']);
array_shift($rootlineArray);
if (!empty($rootlineArray)) {
if (!is_array($hierarchicArray[$currentElement['uid'] . '.'] ?? null)) {
$hierarchicArray[$currentElement['uid'] . '.'] = [];
}
$this->setInPageArray($hierarchicArray[$currentElement['uid'] . '.'], $rootlineArray, $row);
} else {
$hierarchicArray[$currentElement['uid'] . '_'] = $this->extractLinesFromTSConfig($row);
}
}
/**
* Extract the lines of TSConfig from a given pages row.
*/
private function extractLinesFromTSConfig(array $row): array
{
$out = [];
$out['uid'] = $row['uid'];
$lines = GeneralUtility::trimExplode("\r\n", $row['TSconfig']);
$out['writtenLines'] = count($lines);
return $out;
}
/**
* Recursive method to get the list of pages to show.
*/
private function getList(ModuleInterface $currentModule, array $pageArray, array $lines = [], int $pageDepth = 0): array
{
if ($pageArray === []) {
return $lines;
}
foreach ($pageArray as $identifier => $title) {
if (!MathUtility::canBeInterpretedAsInteger($identifier)) {
continue;
}
$line = [];
$line['padding'] = ($pageDepth * 20);
$line['title'] = $identifier;
if (isset($pageArray[$identifier . '_'])) {
$line['link'] = $this->uriBuilder->buildUriFromRoute($currentModule->getIdentifier(), ['id' => $identifier]);
$line['icon'] = $this->iconFactory->getIconForRecord('pages', BackendUtility::getRecordWSOL('pages', $identifier), IconSize::SMALL)->render();
$line['pageTitle'] = GeneralUtility::fixed_lgd_cs($title, 30);
$line['lines'] = ($pageArray[$identifier . '_']['writtenLines'] === 0 ? '' : $pageArray[$identifier . '_']['writtenLines']);
} else {
$line['link'] = '';
$line['icon'] = $this->iconFactory->getIconForRecord('pages', BackendUtility::getRecordWSOL('pages', $identifier), IconSize::SMALL)->render();
$line['pageTitle'] = GeneralUtility::fixed_lgd_cs($title, 30);
$line['lines'] = '';
}
$lines[] = $line;
$lines = $this->getList($currentModule, $pageArray[$identifier . '.'] ?? [], $lines, $pageDepth + 1);
}
return $lines;
}
private function getLanguageService(): LanguageService
{
return $GLOBALS['LANG'];
}
private function getBackendUser(): BackendUserAuthentication
{
return $GLOBALS['BE_USER'];
}
}
+158
View File
@@ -0,0 +1,158 @@
<?php
declare(strict_types=1);
/*
* 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 TYPO3\CMS\Backend\Controller;
use BaconQrCode\Renderer\Image\SvgImageBackEnd;
use BaconQrCode\Renderer\ImageRenderer;
use BaconQrCode\Renderer\RendererStyle\RendererStyle;
use BaconQrCode\Writer;
use Psr\Http\Message\ResponseFactoryInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\StreamFactoryInterface;
use TYPO3\CMS\Backend\Attribute\AsController;
use TYPO3\CMS\Backend\Backend\QrCodeSize;
use TYPO3\CMS\Core\Core\Environment;
use TYPO3\CMS\Core\Imaging\GraphicalFunctions;
use TYPO3\CMS\Core\Resource\MimeTypeDetector;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* @internal This is a concrete controller implementation and is not part of TYPO3 Core API.
*/
#[AsController]
readonly class QrCodeController
{
public function __construct(
protected ResponseFactoryInterface $responseFactory,
protected StreamFactoryInterface $streamFactory,
protected MimeTypeDetector $mimeTypeDetector,
protected GraphicalFunctions $graphicalFunctions,
) {}
public function getQrCodeAction(ServerRequestInterface $request): ResponseInterface
{
$content = $request->getQueryParams()['content'] ?? '';
if ($content === '') {
throw new \InvalidArgumentException('Content of the QR Code cannot be empty', 1762811499);
}
$size = QrCodeSize::tryFrom((string)($request->getQueryParams()['size'] ?? '')) ?? QrCodeSize::MEDIUM;
$svg = $this->getQrCodeSvg($content, $size);
$response = $this->responseFactory->createResponse();
return $response
->withHeader('Content-Type', 'image/svg+xml')
->withBody($this->streamFactory->createStream($svg));
}
public function downloadAction(ServerRequestInterface $request): ResponseInterface
{
$body = $request->getParsedBody();
$content = (string)($body['content'] ?? '');
if ($content === '') {
throw new \InvalidArgumentException('Content of the QR Code cannot be empty', 1762811481);
}
$size = QrCodeSize::tryFrom((string)($body['size'] ?? '')) ?? QrCodeSize::SMALL;
$format = (string)($body['format'] ?? 'svg');
$svgContent = $this->getQrCodeSvg($content, $size);
$path = Environment::getVarPath() . '/transient/';
GeneralUtility::mkdir_deep($path);
$fileName = $path . 'qrcode-' . $size->getSize() . 'px-' . sha1($svgContent);
$svgFilePath = $fileName . '.svg';
if ($format === 'png') {
$pixelGraphicFilePath = $fileName . '.png';
$this->createSvg($svgFilePath, $svgContent);
$filePath = $this->convertTo($svgFilePath, $pixelGraphicFilePath, 'png', $size);
} elseif ($format === 'svg') {
$filePath = $this->createSvg($svgFilePath, $svgContent);
} else {
throw new \InvalidArgumentException('The suffix "' . $format . '" is not supported.', 1762718268);
}
return $this->sendFile($filePath, $format);
}
/**
* Send file to the browser to download
*/
private function sendFile(string $filePath, string $format = 'svg'): ResponseInterface
{
$mimeType = $this->mimeTypeDetector->getMimeTypesForFileExtension($format);
$response = $this->responseFactory->createResponse();
$fileContent = file_get_contents($filePath);
$response->getBody()->write($fileContent);
return $response
->withHeader('Content-Type', $mimeType[0] ?? 'image/svg+xml')
->withHeader('Content-Disposition', 'attachment; filename="' . basename($filePath) . '"')
->withHeader('Content-Length', (string)strlen($fileContent));
}
private function getQrCodeSvg(string $content, QrCodeSize $size = QrCodeSize::MEDIUM): string
{
$qrCodeRenderer = new ImageRenderer(new RendererStyle($size->getSize(), 2), new SvgImageBackEnd());
return (new Writer($qrCodeRenderer))->writeString($content);
}
private function createSvg(string $file, string $content): string
{
if (file_exists($file)) {
return $file;
}
if (!GeneralUtility::writeFile($file, $content, true)) {
throw new \RuntimeException('Unable to write file ' . $file, 1762718307);
}
return $file;
}
/**
* Create a pixel based image file from a given SVG file
*/
private function convertTo(string $sourcePath, string $targetPath, string $format, QrCodeSize $size = QrCodeSize::SMALL): string
{
if (file_exists($targetPath)) {
return $targetPath;
}
$result = $this->graphicalFunctions->resize(
$sourcePath,
$format,
(string)$size->getSize(),
(string)$size->getSize(),
'',
[],
true
);
if ($result) {
$pngFile = $result->getRealPath();
if ($pngFile && is_file($pngFile) && rename($pngFile, $targetPath)) {
return $targetPath;
}
}
throw new \RuntimeException('Failed create ' . strtoupper($format) . ' ' . $targetPath . ' from SVG ' . $sourcePath . ' file.', 1762718351);
}
}
+780
View File
@@ -0,0 +1,780 @@
<?php
declare(strict_types=1);
/*
* 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 TYPO3\CMS\Backend\Controller;
use Psr\EventDispatcher\EventDispatcherInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Backend\Attribute\AsController;
use TYPO3\CMS\Backend\Clipboard\Clipboard;
use TYPO3\CMS\Backend\Clipboard\Type\CountMode;
use TYPO3\CMS\Backend\Context\PageContext;
use TYPO3\CMS\Backend\Context\PageContextFactory;
use TYPO3\CMS\Backend\Controller\Event\RenderAdditionalContentToRecordListEvent;
use TYPO3\CMS\Backend\Module\ModuleData;
use TYPO3\CMS\Backend\RecordList\DatabaseRecordList;
use TYPO3\CMS\Backend\Routing\PreviewUriBuilder;
use TYPO3\CMS\Backend\Routing\UriBuilder;
use TYPO3\CMS\Backend\Security\SudoMode\Exception\VerificationRequiredException;
use TYPO3\CMS\Backend\Template\Components\ButtonBar;
use TYPO3\CMS\Backend\Template\Components\Buttons\LanguageSelectorBuilder;
use TYPO3\CMS\Backend\Template\Components\Buttons\LanguageSelectorMode;
use TYPO3\CMS\Backend\Template\Components\ComponentFactory;
use TYPO3\CMS\Backend\Template\ModuleTemplate;
use TYPO3\CMS\Backend\Template\ModuleTemplateFactory;
use TYPO3\CMS\Backend\Utility\BackendUtility;
use TYPO3\CMS\Backend\View\RecordSearchBoxComponent;
use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
use TYPO3\CMS\Core\DataHandling\DataHandler;
use TYPO3\CMS\Core\Error\Http\BadRequestException;
use TYPO3\CMS\Core\Http\JsonResponse;
use TYPO3\CMS\Core\Imaging\IconFactory;
use TYPO3\CMS\Core\Imaging\IconSize;
use TYPO3\CMS\Core\Localization\LanguageService;
use TYPO3\CMS\Core\Messaging\FlashMessageService;
use TYPO3\CMS\Core\Page\PageRenderer;
use TYPO3\CMS\Core\Schema\Capability\TcaSchemaCapability;
use TYPO3\CMS\Core\Schema\TcaSchemaFactory;
use TYPO3\CMS\Core\Type\Bitmask\Permission;
use TYPO3\CMS\Core\Type\ContextualFeedbackSeverity;
use TYPO3\CMS\Core\Utility\ExtensionManagementUtility;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* The Content > Records module: Rendering the listing of records on a page.
*
* @internal This class is a specific Backend controller implementation and is not part of the TYPO3's Core API.
*/
#[AsController]
class RecordListController
{
protected PageContext $pageContext;
protected string $table = '';
protected string $searchTerm = '';
protected string $returnUrl = '';
protected array $modTSconfig = [];
protected ?ModuleData $moduleData = null;
protected bool $allowClipboard = true;
protected bool $allowSearch = true;
public function __construct(
private readonly ComponentFactory $componentFactory,
protected readonly IconFactory $iconFactory,
protected readonly PageRenderer $pageRenderer,
protected readonly EventDispatcherInterface $eventDispatcher,
protected readonly UriBuilder $uriBuilder,
protected readonly ModuleTemplateFactory $moduleTemplateFactory,
protected readonly TcaSchemaFactory $tcaSchemaFactory,
protected readonly FlashMessageService $flashMessageService,
protected readonly PageContextFactory $pageContextFactory,
protected readonly LanguageSelectorBuilder $languageSelectorBuilder,
) {}
public function mainAction(ServerRequestInterface $request): ResponseInterface
{
$pageContext = $request->getAttribute('pageContext');
if (!$pageContext instanceof PageContext) {
throw new \RuntimeException(
'PageContext not initialized by middleware.',
1731415238
);
}
$this->pageContext = $pageContext;
$this->moduleData = $request->getAttribute('moduleData');
$languageService = $this->getLanguageService();
$backendUser = $this->getBackendUserAuthentication();
$parsedBody = $request->getParsedBody();
$queryParams = $request->getQueryParams();
$this->pageRenderer->addInlineLanguageLabelFile('EXT:core/Resources/Private/Language/locallang_mod_web_list.xlf');
$this->pageRenderer->loadJavaScriptModule('@typo3/backend/element/dispatch-modal-button.js');
BackendUtility::lockRecords();
$pointer = max(0, (int)($parsedBody['pointer'] ?? $queryParams['pointer'] ?? 0));
$this->table = (string)($parsedBody['table'] ?? $queryParams['table'] ?? '');
$this->searchTerm = trim((string)($parsedBody['searchTerm'] ?? $queryParams['searchTerm'] ?? ''));
$this->returnUrl = GeneralUtility::sanitizeLocalUrl((string)($parsedBody['returnUrl'] ?? $queryParams['returnUrl'] ?? ''), $request);
$cmd = (string)($parsedBody['cmd'] ?? $queryParams['cmd'] ?? '');
// RecordList always requires default language (0) for proper record display
// Similar to PageLayoutController's comparison mode behavior
$languagesToDisplay = $this->pageContext->selectedLanguageIds;
if (!in_array(0, $languagesToDisplay, true)) {
$languagesToDisplay = array_merge([0], $languagesToDisplay);
// Create updated PageContext with modified languages and update request
$this->pageContext = $this->pageContextFactory->createWithLanguages(
$request,
$this->pageContext->pageId,
$languagesToDisplay,
$backendUser
);
$request = $request->withAttribute('pageContext', $this->pageContext);
}
$this->moduleData->set('languages', $languagesToDisplay);
$siteLanguages = $this->pageContext->site->getAvailableLanguages($backendUser, false, $this->pageContext->pageId);
$backendUser->pushModuleData($this->moduleData->getModuleIdentifier(), $this->moduleData->toArray());
// Loading module configuration, clean up settings, current page and page access
$this->modTSconfig = $this->pageContext->getModuleTsConfig('web_list');
// Check if Clipboard is allowed to be shown:
if (($this->modTSconfig['enableClipBoard'] ?? '') === 'activated') {
$this->moduleData->set('clipBoard', true);
$this->allowClipboard = false;
} elseif (($this->modTSconfig['enableClipBoard'] ?? '') === 'selectable') {
$this->allowClipboard = true;
} elseif (($this->modTSconfig['enableClipBoard'] ?? '') === 'deactivated') {
$this->moduleData->set('clipBoard', false);
$this->allowClipboard = false;
}
// Check if SearchBox is allowed to be shown:
$this->allowSearch = !($this->modTSconfig['disableSearchBox'] ?? false);
// Overwrite to show search on search request
if (!empty($this->searchTerm)) {
$this->allowSearch = true;
$this->moduleData->set('searchBox', true);
}
// Get search levels from request or fall back to default, set in TSconifg
$search_levels = (int)($parsedBody['search_levels'] ?? $queryParams['search_levels'] ?? $this->modTSconfig['searchLevel']['default'] ?? 0);
$dbList = GeneralUtility::makeInstance(DatabaseRecordList::class);
$dbList->setRequest($request);
$dbList->setModuleData($this->moduleData);
$dbList->calcPerms = $this->pageContext->pagePermissions;
$dbList->returnUrl = $this->returnUrl;
$dbList->showClipboardActions = true;
$dbList->disableSingleTableView = $this->modTSconfig['disableSingleTableView'] ?? false;
$dbList->listOnlyInSingleTableMode = $this->modTSconfig['listOnlyInSingleTableView'] ?? false;
$dbList->hideTables = $this->modTSconfig['hideTables'] ?? '';
$dbList->hideTranslations = (string)($this->modTSconfig['hideTranslations'] ?? '');
$dbList->tableTSconfigOverTCA = $this->modTSconfig['table'] ?? [];
$dbList->allowedNewTables = GeneralUtility::trimExplode(',', $this->modTSconfig['allowedNewTables'] ?? '', true);
$dbList->deniedNewTables = GeneralUtility::trimExplode(',', $this->modTSconfig['deniedNewTables'] ?? '', true);
$dbList->pageRow = $this->pageContext->pageRecord ?? [];
$dbList->modTSconfig = $this->modTSconfig;
$dbList->setLanguagesAllowedForUser($siteLanguages);
$clickTitleMode = trim($this->modTSconfig['clickTitleMode'] ?? '');
$dbList->clickTitleMode = $clickTitleMode === '' ? 'edit' : $clickTitleMode;
if (isset($this->modTSconfig['tableDisplayOrder'])) {
$dbList->setTableDisplayOrder($this->modTSconfig['tableDisplayOrder']);
}
$clipboard = $this->initializeClipboard($request, (bool)$this->moduleData->get('clipBoard'));
$dbList->clipObj = $clipboard;
$additionalRecordListEvent = $this->eventDispatcher->dispatch(new RenderAdditionalContentToRecordListEvent($request));
$view = $this->moduleTemplateFactory->create($request);
$tableListHtml = '';
if ($this->pageContext->isAccessible() || ($this->pageContext->pageId === 0 && $search_levels !== 0 && $this->searchTerm !== '')) {
// If there is access to the page or root page is used for searching, then perform actions and render table list.
if ($cmd === 'delete' && $request->getMethod() === 'POST') {
$this->deleteRecords($request, $clipboard);
}
$dbList->start($this->pageContext->pageId, $this->table, $pointer, $this->searchTerm, $search_levels);
$tableListHtml = $dbList->generateList();
}
if (!$this->pageContext->pageId) {
$title = $GLOBALS['TYPO3_CONF_VARS']['SYS']['sitename'];
} else {
$title = $this->pageContext->getPageTitle();
}
$pageTranslationsHtml = '';
if ($this->pageContext->pageId && !$this->searchTerm && !$cmd && !$this->table && $this->showPageTranslations()) {
// Show page translation table if there are any and display is allowed.
$pageTranslationsHtml = $this->renderPageTranslations($dbList, $siteLanguages);
}
$searchBoxHtml = '';
if ($this->allowSearch && $this->moduleData->get('searchBox')) {
$searchBoxHtml = $this->renderSearchBox($request, $dbList, $this->searchTerm, $search_levels);
}
$clipboardHtml = '';
if ($this->moduleData->get('clipBoard') && ($tableListHtml || $clipboard->hasElements())) {
$clipboardHtml = '<hr class="spacer"><typo3-backend-clipboard-panel return-url="' . htmlspecialchars((string)$dbList->listURL()) . '"></typo3-backend-clipboard-panel>';
}
$view->setTitle($languageService->translate('title', 'backend.modules.list'), $title);
if (empty($tableListHtml)) {
$this->addNoRecordsFlashMessage($view, $this->table);
}
if ($this->pageContext->pageRecord) {
$view->getDocHeaderComponent()->setPageBreadcrumb($this->pageContext->pageRecord);
}
$this->getDocHeaderButtons($view, $clipboard, $request, $dbList);
$view->assignMultiple([
'pageId' => $this->pageContext->pageId,
'pageTitle' => $title,
'isPageEditable' => $this->isPageEditable(),
'additionalContentTop' => $additionalRecordListEvent->getAdditionalContentAbove(),
'pageTranslationsHtml' => $pageTranslationsHtml,
'searchBoxHtml' => $searchBoxHtml,
'tableListHtml' => $tableListHtml,
'clipboardHtml' => $clipboardHtml,
'additionalContentBottom' => $additionalRecordListEvent->getAdditionalContentBelow(),
]);
return $view->renderResponse('RecordList');
}
public function toggleRecordVisibilityAction(ServerRequestInterface $request): ResponseInterface
{
$table = $request->getParsedBody()['table'] ?? null;
$uid = $request->getParsedBody()['uid'] ?? null;
$action = $request->getParsedBody()['action'] ?? null;
try {
if (!isset($action, $table, $uid)) {
throw new BadRequestException('Any of the mandatory argument "table", "uid", "action" is missing', 1729161415);
}
if ($action !== 'show' && $action !== 'hide') {
throw new BadRequestException(sprintf('Passed "action" value must be either "show" or "hide", "%s" given', $action), 1729161479);
}
if (!$this->tcaSchemaFactory->has($table)) {
throw new BadRequestException(sprintf('Cannot execute action for non-existent table "%s"', $table), 1738593519);
}
$schema = $this->tcaSchemaFactory->get($table);
if (!$schema->hasCapability(TcaSchemaCapability::RestrictionDisabledField)) {
throw new \InvalidArgumentException(sprintf('TCA table "%s" does not support record visibility', $table), 1729166628);
}
if (!$this->getBackendUserAuthentication()->check('tables_modify', $table)) {
throw new BadRequestException(sprintf('User has no modify access to table "%s"', $table), 1739376254);
}
$record = BackendUtility::getRecord($table, $uid, 'uid,pid');
if ($record === null) {
throw new BadRequestException(sprintf('A record with uid %d was not found', $uid), 1739376253);
}
$pid = $table === 'pages' ? (int)$record['uid'] : (int)$record['pid'];
$rootLevelCapability = $schema->hasCapability(TcaSchemaCapability::RestrictionRootLevel) ? $schema->getCapability(TcaSchemaCapability::RestrictionRootLevel) : null;
if ($pid !== 0 || !$rootLevelCapability || !$rootLevelCapability->shallIgnoreRootLevelRestriction()) {
if (!BackendUtility::readPageAccess($pid, $this->getBackendUserAuthentication()->getPagePermsClause(Permission::PAGE_SHOW))) {
throw new BadRequestException(sprintf('User has no access to record with uid %d', $uid), 1739376255);
}
}
$hiddenField = $schema->getCapability(TcaSchemaCapability::RestrictionDisabledField)->getFieldName();
$dataHandlerDataMap = [
$table => [
$uid => [
$hiddenField => $action === 'show' ? 0 : 1,
],
],
];
/** @var DataHandler $dataHandler */
$dataHandler = GeneralUtility::makeInstance(DataHandler::class);
$dataHandler->start($dataHandlerDataMap, []);
$dataHandler->process_datamap();
// Prints errors (= write them to the message queue)
$dataHandler->printLogErrorMessages();
$response = [
'messages' => [],
'hasErrors' => false,
];
// Basically the same as in \TYPO3\CMS\Backend\RecordList\DatabaseRecordList->getFieldsToSelect()
$selectFields = [];
$selectFields[] = 'uid';
$selectFields[] = 'pid';
$selectFields[] = $schema->getCapability(TcaSchemaCapability::RestrictionDisabledField)->getFieldName();
if ($table === 'pages') {
$selectFields[] = 'module';
$selectFields[] = 'extendToSubpages';
$selectFields[] = 'nav_hide';
$selectFields[] = 'doktype';
$selectFields[] = 'shortcut';
$selectFields[] = 'shortcut_mode';
$selectFields[] = 'mount_pid';
$selectFields[] = 'is_siteroot';
}
$row = BackendUtility::getRecord($table, $uid, $selectFields);
if ($row !== null) {
// Get new record icon
$recordIcon = $this->iconFactory->getIconForRecord($table, $row, IconSize::SMALL);
$response['icon'] = $recordIcon->render();
$response['isVisible'] = (int)$row[$hiddenField] === 0;
}
$messages = $this->flashMessageService->getMessageQueueByIdentifier()->getAllMessagesAndFlush();
foreach ($messages as $message) {
$response['messages'][] = [
'title' => $message->getTitle(),
'message' => $message->getMessage(),
'severity' => $message->getSeverity(),
];
if ($message->getSeverity() === ContextualFeedbackSeverity::ERROR) {
$response['hasErrors'] = true;
}
}
} catch (VerificationRequiredException $e) {
// Handled by Middleware/SudoModeInterceptor
throw $e;
} catch (\Throwable $e) {
// @todo: having this explicit handling here sucks
$response = [
'messages' => [
[
'title' => 'An exception occurred',
'message' => $e->getMessage(),
'severity' => ContextualFeedbackSeverity::ERROR,
],
],
'hasErrors' => true,
];
}
return new JsonResponse($response, $response['hasErrors'] ? 400 : 200);
}
/**
* Process incoming data and configure the clipboard.
*/
protected function initializeClipboard(ServerRequestInterface $request, bool $isClipboardShown): Clipboard
{
$clipboard = GeneralUtility::makeInstance(Clipboard::class);
$cmd = (string)($request->getParsedBody()['cmd'] ?? $request->getQueryParams()['cmd'] ?? '');
// Initialize - reads the clipboard content from the user session
$clipboard->initializeClipboard($request);
// Clipboard actions are handled:
$clipboardCommandArray = array_replace_recursive($request->getQueryParams()['CB'] ?? [], $request->getParsedBody()['CB'] ?? []);
if ($cmd === 'copyMarked' || $cmd === 'removeMarked') {
// Get CBC from request, and map the element values (true => copy, false => remove)
$CBC = array_map(static fn(): bool => ($cmd === 'copyMarked'), (array)($request->getParsedBody()['CBC'] ?? []));
$cmd_table = (string)($request->getParsedBody()['cmd_table'] ?? $request->getQueryParams()['cmd_table'] ?? '');
// Cleanup CBC
$clipboardCommandArray['el'] = $clipboard->cleanUpCBC($CBC, $cmd_table);
}
if (!$isClipboardShown) {
// If the clipboard is NOT shown, set the pad to 'normal'.
$clipboardCommandArray['setP'] = 'normal';
}
// Execute commands.
$clipboard->setCmd($clipboardCommandArray);
// Clean up pad
$clipboard->cleanCurrent();
// Save the clipboard content
$clipboard->endClipboard();
return $clipboard;
}
protected function deleteRecords(ServerRequestInterface $request, Clipboard $clipboard): void
{
// This is the 'delete' button in table header with multi record selection.
// The clipboard object is used to clean up the submitted entries to only the selected table.
$parsedBody = $request->getParsedBody();
$items = $clipboard->cleanUpCBC((array)($parsedBody['CBC'] ?? []), (string)($parsedBody['cmd_table'] ?? ''), true);
if (!empty($items)) {
// Create data handler command array
$dataHandlerCmd = [];
foreach ($items as $iK => $value) {
$iKParts = explode('|', (string)$iK);
$dataHandlerCmd[$iKParts[0]][$iKParts[1]]['delete'] = 1;
}
$tce = GeneralUtility::makeInstance(DataHandler::class);
$tce->start([], $dataHandlerCmd);
$tce->process_cmdmap();
if (isset($dataHandlerCmd['pages'])) {
BackendUtility::setUpdateSignal('updatePageTree');
}
$tce->printLogErrorMessages();
}
}
protected function renderSearchBox(ServerRequestInterface $request, DatabaseRecordList $dbList, string $searchWord, int $searchLevels): string
{
$searchBox = GeneralUtility::makeInstance(RecordSearchBoxComponent::class)
->setAllowedSearchLevels((array)($this->modTSconfig['searchLevel']['items'] ?? []))
->setSearchWord($searchWord)
->setSearchLevel($searchLevels)
->render($request, $dbList->listURL('', null, 'pointer,searchTerm'));
return $searchBox;
}
/**
* Create the panel of buttons for submitting the form or otherwise perform operations.
*/
protected function getDocHeaderButtons(ModuleTemplate $view, Clipboard $clipboard, ServerRequestInterface $request, DatabaseRecordList $dbList): void
{
$queryParams = $request->getQueryParams();
$lang = $this->getLanguageService();
// Language selector (top right area)
$this->createLanguageSelector($view, $request);
if (!($this->modTSconfig['noCreateRecordsLink'] ?? false) && $this->editLockPermissions()) {
if ($this->table === '') {
// "General" new record button if: not in single table view, not disabled via TSconfig and page is not 'edit locked'
$newRecordButton = $this->componentFactory->createLinkButton()
->setHref((string)$this->uriBuilder->buildUriFromRoute('db_new', ['id' => $this->pageContext->pageId, 'returnUrl' => $dbList->listURL()]))
->setTitle($lang->sL('LLL:EXT:core/Resources/Private/Language/locallang_mod_web_list.xlf:newRecordGeneral'))
->setShowLabelText(true)
->setIcon($this->iconFactory->getIcon('actions-plus', IconSize::SMALL));
$view->addButtonToButtonBar($newRecordButton, ButtonBar::BUTTON_POSITION_LEFT, 10);
} elseif (($createNewRecordButton = $dbList->createActionButtonNewRecord($this->table)) !== null) {
// In single table view, render the specific create new button
$view->addButtonToButtonBar($createNewRecordButton);
}
}
if ($this->pageContext->isAccessible() && $this->pageContext->pageId > 0) {
$uriBuilder = PreviewUriBuilder::create($this->pageContext->pageRecord);
if ($uriBuilder->isPreviewable()) {
$view->addButtonToButtonBar(
$this->componentFactory->createViewButton(PreviewUriBuilder::create($this->pageContext->pageRecord)
->withRootLine($this->pageContext->rootLine)
->buildDispatcherDataAttributes() ?? []),
ButtonBar::BUTTON_POSITION_LEFT,
15
);
// QR Code button
$fallbackUri = $uriBuilder
->withRootLine($this->pageContext->rootLine)
->buildUri();
$previewUri = $this->componentFactory->getPreviewUrlForQrCode(
$this->pageContext->pageId,
$this->pageContext->getPrimaryLanguageId(),
$fallbackUri
);
if ($previewUri !== null) {
$view->addButtonToButtonBar(
$this->componentFactory->createQrCodeButton($previewUri),
ButtonBar::BUTTON_POSITION_LEFT,
15
);
}
}
// If edit permissions are set, see BackendUserAuthentication
if ($this->isPageEditable()) {
// Edit
$editLink = $this->uriBuilder->buildUriFromRoute('record_edit', [
'edit' => [
'pages' => [
$this->pageContext->pageId => 'edit',
],
],
'module' => 'records',
'returnUrl' => $dbList->listURL(),
]);
$editButton = $this->componentFactory->createLinkButton()
->setHref((string)$editLink)
->setTitle($lang->sL('LLL:EXT:core/Resources/Private/Language/locallang_mod_web_list.xlf:editPage'))
->setShowLabelText(true)
->setIcon($this->iconFactory->getIcon('actions-page-open', IconSize::SMALL));
$view->addButtonToButtonBar($editButton, ButtonBar::BUTTON_POSITION_LEFT, 20);
}
}
// Paste
if (($this->pageContext->pagePermissions->createPagePermissionIsGranted() || $this->pageContext->pagePermissions->editContentPermissionIsGranted()) && $this->editLockPermissions()) {
$elFromTable = $clipboard->elFromTable();
if (!empty($elFromTable)) {
$confirmMessage = $clipboard->confirmMsgText('pages', $this->pageContext->pageRecord, 'into', CountMode::ALL);
$pasteButton = $this->componentFactory->createLinkButton()
->setHref($clipboard->pasteUrl('', $this->pageContext->pageId))
->setTitle($lang->sL('LLL:EXT:core/Resources/Private/Language/locallang_mod_web_list.xlf:clip_paste'))
->setClasses('t3js-modal-trigger')
->setDataAttributes([
'severity' => 'warning',
'content' => $confirmMessage,
'title' => $lang->sL('LLL:EXT:core/Resources/Private/Language/locallang_mod_web_list.xlf:clip_paste'),
])
->setIcon($this->iconFactory->getIcon('actions-document-paste-into', IconSize::SMALL))
->setShowLabelText(true);
$view->addButtonToButtonBar($pasteButton, ButtonBar::BUTTON_POSITION_LEFT, 40);
}
}
// Cache
if ($this->pageContext->pageId) {
$clearCacheButton = $this->componentFactory->createGenericButton()
->setTag('button')
->setLabel($lang->sL('core.cache:page.label'))
->setClasses('t3js-clear-page-cache')
->setAttributes(['type' => 'button', 'data-id' => (string)$this->pageContext->pageId])
->setIcon($this->iconFactory->getIcon('actions-system-cache-clear', IconSize::SMALL));
$view->addButtonToButtonBar($clearCacheButton, ButtonBar::BUTTON_POSITION_RIGHT);
}
if ($this->table
&& !($this->modTSconfig['noExportRecordsLinks'] ?? false)
&& $this->getBackendUserAuthentication()->isExportEnabled()
) {
// Export
if (ExtensionManagementUtility::isLoaded('impexp')) {
$url = (string)$this->uriBuilder->buildUriFromRoute('tx_impexp_export', ['tx_impexp' => ['list' => [$this->table . ':' . $this->pageContext->pageId]]]);
$exportButton = $this->componentFactory->createLinkButton()
->setHref($url)
->setTitle($lang->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:rm.export'))
->setIcon($this->iconFactory->getIcon('actions-document-export-t3d', IconSize::SMALL))
->setShowLabelText(true);
$view->addButtonToButtonBar($exportButton, ButtonBar::BUTTON_POSITION_LEFT, 50);
}
}
// ViewMode
$viewModeItems = [];
if ($this->allowSearch) {
$viewModeItems[] = $this->componentFactory->createDropDownToggle()
->setActive((bool)$this->moduleData->get('searchBox'))
->setHref($this->createModuleUri($request, ['searchBox' => $this->moduleData->get('searchBox') ? 0 : 1, 'searchTerm' => '']))
->setLabel($lang->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.view.showSearch'))
->setIcon($this->iconFactory->getIcon('actions-search'));
}
if ($this->allowClipboard) {
$viewModeItems[] = $this->componentFactory->createDropDownToggle()
->setActive((bool)$this->moduleData->get('clipBoard'))
->setHref($this->createModuleUri($request, ['clipBoard' => $this->moduleData->get('clipBoard') ? 0 : 1]))
->setLabel($lang->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.view.showClipboard'))
->setIcon($this->iconFactory->getIcon('actions-clipboard'));
}
if (!empty($viewModeItems)) {
$viewModeButton = $this->componentFactory->createDropDownButton()
->setLabel($lang->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.view'))
->setIcon($this->iconFactory->getIcon('actions-cog'))
->setShowLabelText(true);
foreach ($viewModeItems as $viewModeItem) {
$viewModeButton->addItem($viewModeItem);
}
$view->addButtonToButtonBar($viewModeButton, ButtonBar::BUTTON_POSITION_RIGHT, 0);
}
// Shortcut
$arguments = [
'id' => $this->pageContext->pageId,
];
$potentialArguments = [
'pointer',
'table',
'searchTerm',
'search_levels',
'sortField',
'sortRev',
];
foreach ($potentialArguments as $argument) {
if (!empty($queryParams[$argument])) {
$arguments[$argument] = $queryParams[$argument];
}
}
$view->getDocHeaderComponent()->setShortcutContext('records', $this->getShortcutTitle($arguments), $arguments);
// Back
if ($this->returnUrl) {
$view->addButtonToButtonBar($this->componentFactory->createBackButton($this->returnUrl));
}
}
protected function addNoRecordsFlashMessage(ModuleTemplate $view, string $table)
{
$languageService = $this->getLanguageService();
if ($table && $this->tcaSchemaFactory->has($table) && $this->tcaSchemaFactory->get($table)->getTitle() !== '') {
$message = sprintf(
$languageService->sL('LLL:EXT:core/Resources/Private/Language/locallang_mod_web_list.xlf:noRecordsOfTypeOnThisPage'),
$this->tcaSchemaFactory->get($table)->getTitle($languageService->sL(...))
);
} else {
$message = $languageService->sL('LLL:EXT:core/Resources/Private/Language/locallang_mod_web_list.xlf:noRecordsOnThisPage');
}
$view->addFlashMessage($message, '', ContextualFeedbackSeverity::INFO);
}
/**
* Check whether the current backend user is an admin or the current page is locked by edit lock.
*/
protected function editLockPermissions(): bool
{
return $this->getBackendUserAuthentication()->isAdmin()
|| !($schema = $this->tcaSchemaFactory->get('pages'))->hasCapability(TcaSchemaCapability::EditLock)
|| !($this->pageContext->pageRecord[$schema->getCapability(TcaSchemaCapability::EditLock)->getFieldName()] ?? false);
}
/**
* Returns the shortcut title for the current page.
*/
protected function getShortcutTitle(array $arguments): string
{
$tableTitle = '';
$languageService = $this->getLanguageService();
if (isset($arguments['table'])) {
$tableName = $arguments['table'];
if ($this->tcaSchemaFactory->has($tableName)) {
$schema = $this->tcaSchemaFactory->get($tableName);
$tableTitle = $schema->getTitle($languageService->sL(...));
}
$tableTitle = ': ' . ($tableTitle ?: $tableName);
}
return trim(sprintf(
$languageService->translate('shortcut.title', 'backend.messages'),
$languageService->translate('title', 'backend.modules.list'),
$tableTitle,
$this->pageContext->getPageTitle(),
$this->pageContext->pageId
));
}
protected function showPageTranslations(): bool
{
if (!$this->getBackendUserAuthentication()->check('tables_select', 'pages')) {
return false;
}
if (isset($this->modTSconfig['table']['pages']['hideTable'])) {
return !$this->modTSconfig['table']['pages']['hideTable'];
}
$schema = $this->tcaSchemaFactory->get('pages');
$hideTables = $this->modTSconfig['hideTables'] ?? '';
return !$schema->hasCapability(TcaSchemaCapability::HideInUi)
&& $hideTables !== '*'
&& !in_array('pages', GeneralUtility::trimExplode(',', $hideTables), true);
}
protected function renderPageTranslations(DatabaseRecordList $dbList, array $siteLanguages): string
{
$pageTranslationsDatabaseRecordList = clone $dbList;
$pageTranslationsDatabaseRecordList->id = $this->pageContext->pageId;
$pageTranslationsDatabaseRecordList->listOnlyInSingleTableMode = false;
$pageTranslationsDatabaseRecordList->disableSingleTableView = true;
$pageTranslationsDatabaseRecordList->deniedNewTables = ['pages'];
$pageTranslationsDatabaseRecordList->hideTranslations = '';
$pageTranslationsDatabaseRecordList->setLanguagesAllowedForUser($siteLanguages);
$pageTranslationsDatabaseRecordList->showOnlyTranslatedRecords(true);
return $pageTranslationsDatabaseRecordList->getTable('pages');
}
protected function createModuleUri(ServerRequestInterface $request, array $params = []): string
{
$params = array_replace_recursive([
'id' => $this->pageContext->pageId,
'table' => $this->table,
'searchTerm' => $this->searchTerm,
], $params);
$params = array_filter($params, static function (mixed $value): bool {
return $value !== null && trim((string)$value) !== '';
});
return (string)$this->uriBuilder->buildUriFromRequest($request, $params);
}
/**
* Creates the language selector dropdown in the module toolbar.
*/
protected function createLanguageSelector(ModuleTemplate $view, ServerRequestInterface $request): void
{
if (count($this->pageContext->languageInformation->availableLanguages) <= 1) {
return;
}
$languageSelector = $this->languageSelectorBuilder->build(
$this->pageContext,
LanguageSelectorMode::MULTI_SELECT,
fn(array $languageIds): string => $this->buildListUrl($request, ['languages' => $languageIds]),
!empty($this->pageContext->languageInformation->existingTranslations)
);
$view->getDocHeaderComponent()->setLanguageSelector($languageSelector);
}
/**
* Check if page can be edited by current user
*/
protected function isPageEditable(): bool
{
$schema = $this->tcaSchemaFactory->get('pages');
if ($schema->hasCapability(TcaSchemaCapability::AccessReadOnly)) {
return false;
}
$backendUser = $this->getBackendUserAuthentication();
if ($backendUser->isAdmin()) {
return true;
}
if ($schema->hasCapability(TcaSchemaCapability::AccessAdminOnly)) {
return false;
}
return !empty($this->pageContext->pageRecord)
&& $this->editLockPermissions()
&& $this->pageContext->pagePermissions->editPagePermissionIsGranted()
&& $backendUser->checkLanguageAccess(0)
&& $backendUser->check('tables_modify', 'pages');
}
/**
* Build list URL preserving relevant parameters (search, table, sort).
* Does NOT preserve pagination (pointer) to allow resetting to first page.
*
* @param array $additionalParams Additional parameters to add/override (e.g., ['languages' => [0, 1]])
*/
protected function buildListUrl(ServerRequestInterface $request, array $additionalParams = []): string
{
$queryParams = $request->getQueryParams();
$parsedBody = $request->getParsedBody();
$urlParams = [
'id' => $this->pageContext->pageId,
];
if ($this->table !== '') {
$urlParams['table'] = $this->table;
}
if ($this->searchTerm !== '') {
$urlParams['searchTerm'] = $this->searchTerm;
}
$searchLevels = (int)($parsedBody['search_levels'] ?? $queryParams['search_levels'] ?? 0);
if ($searchLevels > 0) {
$urlParams['search_levels'] = $searchLevels;
}
$sortField = (string)($parsedBody['sortField'] ?? $queryParams['sortField'] ?? '');
if ($sortField !== '') {
$urlParams['sortField'] = $sortField;
}
$sortRev = $parsedBody['sortRev'] ?? $queryParams['sortRev'] ?? null;
if ($sortRev !== null) {
$urlParams['sortRev'] = $sortRev;
}
// Merge with additional parameters (which can override preserved ones)
$urlParams = array_merge($urlParams, $additionalParams);
return (string)$this->uriBuilder->buildUriFromRoute('records', $urlParams);
}
protected function getBackendUserAuthentication(): BackendUserAuthentication
{
return $GLOBALS['BE_USER'];
}
protected function getLanguageService(): LanguageService
{
return $GLOBALS['LANG'];
}
}
@@ -0,0 +1,412 @@
<?php
declare(strict_types=1);
/*
* 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 TYPO3\CMS\Backend\Controller;
use Psr\EventDispatcher\EventDispatcherInterface;
use Psr\Http\Message\ResponseFactoryInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Backend\Attribute\AsController;
use TYPO3\CMS\Backend\Configuration\TranslationConfigurationProvider;
use TYPO3\CMS\Backend\Exception\AccessDeniedException;
use TYPO3\CMS\Backend\RecordList\DatabaseRecordList;
use TYPO3\CMS\Backend\RecordList\DownloadRecordList;
use TYPO3\CMS\Backend\RecordList\Event\BeforeRecordDownloadIsExecutedEvent;
use TYPO3\CMS\Backend\RecordList\Event\BeforeRecordDownloadPresetsAreDisplayedEvent;
use TYPO3\CMS\Backend\Utility\BackendUtility;
use TYPO3\CMS\Backend\View\BackendViewFactory;
use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
use TYPO3\CMS\Core\Context\Context;
use TYPO3\CMS\Core\Localization\LanguageService;
use TYPO3\CMS\Core\Schema\TcaSchemaFactory;
use TYPO3\CMS\Core\Type\Bitmask\Permission;
use TYPO3\CMS\Core\Utility\CsvUtility;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Controller for handling download of records, typically executed from the records module.
*
* @internal This class is a specific Backend controller implementation and is not part of the TYPO3's Core API.
*/
#[AsController]
class RecordListDownloadController
{
private const array DOWNLOAD_FORMATS = [
'csv' => [
'options' => [
'delimiter' => [
'comma' => ',',
'semicolon' => ';',
'pipe' => '|',
],
'quote' => [
'doublequote' => '"',
'singlequote' => '\'',
'space' => ' ',
],
],
'defaults' => [
'delimiter' => ',',
'quote' => '"',
],
],
'json' => [
'options' => [
'meta' => [
'full' => 'full',
'prefix' => 'prefix',
'none' => 'none',
],
],
'defaults' => [
'meta' => 'prefix',
],
],
];
protected int $id = 0;
protected string $table = '';
protected string $format = '';
protected string $filename = '';
protected array $modTSconfig = [];
public function __construct(
protected readonly ResponseFactoryInterface $responseFactory,
protected readonly BackendViewFactory $backendViewFactory,
protected readonly EventDispatcherInterface $eventDispatcher,
protected readonly TcaSchemaFactory $tcaSchemaFactory,
) {}
/**
* Handle record download request by evaluating the provided arguments,
* checking access, initializing the record list, fetching records and
* finally calling the requested download format action (e.g. csv).
*/
public function handleDownloadRequest(ServerRequestInterface $request): ResponseInterface
{
$parsedBody = $request->getParsedBody();
$this->table = (string)($parsedBody['table'] ?? '');
if ($this->table === '') {
throw new \RuntimeException('No table was given for downloading records', 1623941276);
}
$backendUser = $this->getBackendUserAuthentication();
if (!$backendUser->check('tables_select', $this->table)) {
throw new AccessDeniedException('Insufficient permissions for accessing this download', 1756895674);
}
// @todo we might want to throw an exception in case no schema exists for the table
$schema = $this->tcaSchemaFactory->has($this->table) ? $this->tcaSchemaFactory->get($this->table) : null;
$this->format = (string)($parsedBody['format'] ?? '');
if ($this->format === '' || !isset(self::DOWNLOAD_FORMATS[$this->format])) {
throw new \RuntimeException('No or an invalid download format given', 1624562166);
}
$this->filename = $this->generateFilename((string)($parsedBody['filename'] ?? ''));
$this->id = (int)($parsedBody['id'] ?? 0);
// Loading module configuration
$this->modTSconfig = BackendUtility::getPagesTSconfig($this->id)['mod.']['web_list.'] ?? [];
// Loading TCEFORM for the table
$tsConfig = BackendUtility::getPagesTSconfig($this->id)['TCEFORM.'][$this->table . '.'] ?? null;
$tsConfig = is_array($tsConfig) ? $tsConfig : null;
// Loading current page record and checking access
$perms_clause = $backendUser->getPagePermsClause(Permission::PAGE_SHOW);
$pageinfo = BackendUtility::readPageAccess($this->id, $perms_clause);
$searchString = (string)($parsedBody['searchString'] ?? '');
$searchLevels = (int)($parsedBody['searchLevels'] ?? $this->modTSconfig['searchLevel.']['default'] ?? 0);
if (!is_array($pageinfo) && !($this->id === 0 && $searchString !== '' && $searchLevels !== 0)) {
throw new AccessDeniedException('Insufficient permissions for accessing this download', 1623941361);
}
$rawValues = (bool)($parsedBody['rawValues'] ?? false);
// Initialize database record list
$recordList = GeneralUtility::makeInstance(DatabaseRecordList::class);
$recordList->setRequest($request);
$recordList->modTSconfig = $this->modTSconfig;
$recordList->setLanguagesAllowedForUser($this->getSiteLanguages($request));
$recordList->start($this->id, $this->table, 0, $searchString, $searchLevels);
$selectedPreset = (string)($parsedBody['preset'] ?? '');
if (($parsedBody['allColumns'] ?? false) || $selectedPreset !== '') {
// Overwrite setFields in case all allowed columns should be included,
// or a preset is selected (that is only allowed to pick from the maximum
// allowed set of columns).
$recordList->setFields[$this->table] = BackendUtility::getAllowedFieldsForTable($this->table);
}
$columnsToRender = $recordList->getColumnsToRender($this->table, false, $selectedPreset);
$hideTranslations = ($this->modTSconfig['hideTranslations'] ?? '') === '*'
|| GeneralUtility::inList($this->modTSconfig['hideTranslations'] ?? '', $this->table);
// Initialize the downloader
$downloader = GeneralUtility::makeInstance(
DownloadRecordList::class,
$recordList,
GeneralUtility::makeInstance(TranslationConfigurationProvider::class),
$this->tcaSchemaFactory,
);
// Fetch and process the header row and the records
$headerRow = $downloader->getHeaderRow($columnsToRender);
if (!$rawValues) {
foreach ($headerRow as &$headerField) {
$label = $schema?->hasField($headerField) ? $schema->getField($headerField)->getLabel() : null;
if ($label !== null) {
$headerField = rtrim(trim($this->getLanguageService()->translateLabel($tsConfig[$headerField . '.']['label.'] ?? [], $tsConfig[$headerField . '.']['label'] ?? $label)), ':');
} elseif ($specialLabel = $this->getLanguageService()->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.' . $headerField)) {
// Special label exists for this field (Probably a management field, e.g. sorting)
$headerField = $specialLabel;
}
}
unset($headerField);
}
$records = $downloader->getRecords(
$this->table,
$columnsToRender,
$this->getBackendUserAuthentication(),
$hideTranslations,
$rawValues
);
$event = $this->eventDispatcher->dispatch(
new BeforeRecordDownloadIsExecutedEvent(
$headerRow,
$records,
$request,
$this->table,
$this->format,
$this->filename,
$this->id,
$this->modTSconfig,
$columnsToRender,
$hideTranslations,
)
);
$downloadAction = $this->format . 'DownloadAction';
return $this->{$downloadAction}($request, $event->getHeaderRow(), $event->getRecords());
}
/**
* Generate settings form for the download request
*/
public function downloadSettingsAction(ServerRequestInterface $request): ResponseInterface
{
$downloadArguments = $request->getQueryParams();
$this->table = (string)($downloadArguments['table'] ?? '');
if ($this->table === '') {
throw new \RuntimeException('No table was given for downloading records', 1624551586);
}
$this->id = (int)($downloadArguments['id'] ?? 0);
$this->modTSconfig = BackendUtility::getPagesTSconfig($this->id)['mod.']['web_list.'] ?? [];
$presets = $this->eventDispatcher->dispatch(
new BeforeRecordDownloadPresetsAreDisplayedEvent(
$this->table,
$this->modTSconfig['downloadPresets.'][$this->table . '.'] ?? [],
$request,
$this->id,
)
)->getPresets();
$view = $this->backendViewFactory->create($request);
$view->assignMultiple([
'table' => $this->table,
'downloadArguments' => $downloadArguments,
'formats' => array_keys(self::DOWNLOAD_FORMATS),
'formatOptions' => $this->getFormatOptionsWithResolvedDefaults(),
'presets' => $presets,
]);
$response = $this->responseFactory->createResponse()
->withHeader('Content-Type', 'text/html; charset=utf-8');
$response->getBody()->write($view->render('RecordDownloadSettings'));
return $response;
}
/**
* Generating an download in CSV format
*/
protected function csvDownloadAction(
ServerRequestInterface $request,
array $headerRow,
array $records
): ResponseInterface {
// Fetch csv related format options
$csvDelimiter = (string)$this->getFormatOption($request, 'delimiter');
$csvQuote = (string)$this->getFormatOption($request, 'quote');
// Create result
$result[] = CsvUtility::csvValues($headerRow, $csvDelimiter, $csvQuote);
foreach ($records as $record) {
$result[] = CsvUtility::csvValues($record, $csvDelimiter, $csvQuote);
}
return $this->generateDownloadResponse(implode(CRLF, $result));
}
/**
* Generating an download in JSON format
*/
protected function jsonDownloadAction(
ServerRequestInterface $request,
array $headerRow,
array $records
): ResponseInterface {
// Fetch and evaluate json related format option
switch ($this->getFormatOption($request, 'meta')) {
case 'prefix':
$result = [$this->table . ':' . $this->id => $records];
break;
case 'full':
$user = $this->getBackendUserAuthentication();
$parsedBody = $request->getParsedBody();
$result = [
'meta' => [
'table' => $this->table,
'page' => $this->id,
'timestamp' => GeneralUtility::makeInstance(Context::class)->getPropertyFromAspect('date', 'timestamp'),
'user' => $user->getUserName() ?? '',
'site' => $GLOBALS['TYPO3_CONF_VARS']['SYS']['sitename'] ?? '',
'options' => [
'columns' => array_values($headerRow),
'values' => ($parsedBody['rawvalues'] ?? false) ? 'raw' : 'processed',
],
],
'records' => $records,
];
$searchString = (string)($parsedBody['searchString'] ?? '');
$searchLevels = (int)($parsedBody['searchLevels'] ?? 0);
if ($searchString !== '' || $searchLevels !== 0) {
$result['meta']['search'] = [
'searchTerm' => $searchString,
'searchLevels' => $searchLevels,
];
}
break;
case 'none':
default:
$result = $records;
break;
}
return $this->generateDownloadResponse(json_encode($result) ?: '');
}
/**
* Get site languages, available for the current backend user
*/
protected function getSiteLanguages(ServerRequestInterface $request): array
{
$site = $request->getAttribute('site');
return $site->getAvailableLanguages($this->getBackendUserAuthentication(), false, $this->id);
}
/**
* Return an evaluated and processed custom filename or a
* default, if non or an invalid custom filename was provided.
*/
protected function generateFilename(string $filename): string
{
$defaultFilename = $this->table . '_' . date('dmy-Hi') . '.' . $this->format;
// Return default filename if given filename is empty or not valid
if ($filename === '' || !preg_match('/^[0-9a-z._\-]+$/i', $filename)) {
return $defaultFilename;
}
$extension = pathinfo($filename, PATHINFO_EXTENSION);
if ($extension === '') {
// Add original extension in case alternative filename did not contain any
$filename = rtrim($filename, '.') . '.' . $this->format;
}
// Check if given or resolved extension matches the original one
return pathinfo($filename, PATHINFO_EXTENSION) === $this->format ? $filename : $defaultFilename;
}
/**
* Return the format options with resolved default values from TSconfig
*/
protected function getFormatOptionsWithResolvedDefaults(): array
{
$formatOptions = self::DOWNLOAD_FORMATS;
if ($this->modTSconfig === []) {
return $formatOptions;
}
if ($this->modTSconfig['csvDelimiter'] ?? false) {
$default = (string)$this->modTSconfig['csvDelimiter'];
if (!in_array($default, $formatOptions['csv']['options']['delimiter'], true)) {
// In case the user defined option is not yet available as format options, add it
$formatOptions['csv']['options']['delimiter']['custom'] = $default;
}
$formatOptions['csv']['defaults']['delimiter'] = $default;
}
if ($this->modTSconfig['csvQuote'] ?? false) {
$default = (string)$this->modTSconfig['csvQuote'];
if (!in_array($default, $formatOptions['csv']['options']['quote'], true)) {
// In case the user defined option is not yet available as format options, add it
$formatOptions['csv']['options']['quote']['custom'] = $default;
}
$formatOptions['csv']['defaults']['quote'] = $default;
}
return $formatOptions;
}
protected function getFormatOptions(ServerRequestInterface $request): array
{
return $request->getParsedBody()[$this->format] ?? [];
}
protected function getFormatOption(ServerRequestInterface $request, string $option, $default = null)
{
return $this->getFormatOptions($request)[$option]
?? $this->getFormatOptionsWithResolvedDefaults()[$this->format]['defaults'][$option]
?? $default;
}
protected function generateDownloadResponse(string $result): ResponseInterface
{
$response = $this->responseFactory->createResponse()
->withHeader('Content-Type', 'application/octet-stream')
->withHeader('Content-Disposition', 'attachment; filename=' . $this->filename);
$response->getBody()->write($result);
return $response;
}
protected function getBackendUserAuthentication(): BackendUserAuthentication
{
return $GLOBALS['BE_USER'];
}
protected function getLanguageService(): LanguageService
{
return $GLOBALS['LANG'];
}
}
@@ -0,0 +1,278 @@
<?php
declare(strict_types=1);
/*
* 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 TYPO3\CMS\Backend\Controller;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Backend\Attribute\AsController;
use TYPO3\CMS\Backend\Authentication\PasswordReset;
use TYPO3\CMS\Backend\Routing\RouteRedirect;
use TYPO3\CMS\Backend\Routing\UriBuilder;
use TYPO3\CMS\Backend\Template\PageRendererBackendSetupTrait;
use TYPO3\CMS\Backend\View\AuthenticationStyleInformation;
use TYPO3\CMS\Backend\View\BackendViewFactory;
use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
use TYPO3\CMS\Core\Configuration\ExtensionConfiguration;
use TYPO3\CMS\Core\Configuration\Features;
use TYPO3\CMS\Core\Context\Context;
use TYPO3\CMS\Core\Http\PropagateResponseException;
use TYPO3\CMS\Core\Http\RedirectResponse;
use TYPO3\CMS\Core\Information\Typo3Information;
use TYPO3\CMS\Core\Localization\LanguageService;
use TYPO3\CMS\Core\Localization\Locales;
use TYPO3\CMS\Core\Page\PageRenderer;
use TYPO3\CMS\Core\PasswordPolicy\PasswordPolicyAction;
use TYPO3\CMS\Core\PasswordPolicy\PasswordPolicyValidator;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Core\View\ViewInterface;
/**
* Controller responsible for rendering and processing backend user password reset requests.
*
* @internal This class is a specific Backend controller implementation and is not considered part of the Public TYPO3 API.
*/
#[AsController]
class ResetPasswordController
{
use PageRendererBackendSetupTrait;
protected string $loginProvider = '';
protected ViewInterface $view;
public function __construct(
protected readonly Context $context,
protected readonly Locales $locales,
protected readonly Features $features,
protected readonly UriBuilder $uriBuilder,
protected readonly PageRenderer $pageRenderer,
protected readonly PasswordReset $passwordReset,
protected readonly Typo3Information $typo3Information,
protected readonly AuthenticationStyleInformation $authenticationStyleInformation,
protected readonly ExtensionConfiguration $extensionConfiguration,
protected readonly BackendViewFactory $backendViewFactory,
) {}
/**
* Show a form to enter an email address to request a password reset email.
*/
public function forgetPasswordFormAction(ServerRequestInterface $request): ResponseInterface
{
if (!$this->passwordReset->isEnabled()) {
return $this->redirectToLoginForm($request);
}
$this->initialize($request);
$this->initializeForgetPasswordView($request);
$this->pageRenderer->setBodyContent('<body>' . $this->view->render('Login/ForgetPasswordForm'));
return $this->pageRenderer->renderResponse($request);
}
/**
* Validate the email address.
*
* Restricted to POST method in Configuration/Backend/Routes.php
*/
public function initiatePasswordResetAction(ServerRequestInterface $request): ResponseInterface
{
if (!$this->passwordReset->isEnabled()) {
return $this->redirectToLoginForm($request);
}
$this->initialize($request);
$this->initializeForgetPasswordView($request);
$emailAddress = $request->getParsedBody()['email'] ?? '';
if (!is_string($emailAddress)) {
$emailAddress = '';
}
$this->view->assign('email', $emailAddress);
if (!GeneralUtility::validEmail($emailAddress)) {
$this->view->assign('invalidEmail', true);
} else {
$this->passwordReset->initiateReset($request, $this->context, $emailAddress);
$this->view->assign('resetInitiated', true);
}
$this->pageRenderer->setBodyContent('<body>' . $this->view->render('Login/ForgetPasswordForm'));
// Prevent time based information disclosure by waiting a random time
// before sending a response. This prevents that the response time
// can be an indicator if the used email exists or not. Wait a random
// time between 200 milliseconds and 3 seconds.
usleep(random_int(200000, 3000000));
return $this->pageRenderer->renderResponse($request);
}
/**
* Validates the link and show a form to enter the new password.
*/
public function passwordResetAction(ServerRequestInterface $request): ResponseInterface
{
if (!$this->passwordReset->isEnabled()) {
return $this->redirectToLoginForm($request);
}
$this->initialize($request);
$this->initializeResetPasswordView($request);
if (!$this->passwordReset->isValidResetTokenFromRequest($request)) {
$this->view->assign('invalidToken', true);
}
$this->pageRenderer->setBodyContent('<body>' . $this->view->render('Login/ResetPasswordForm'));
return $this->pageRenderer->renderResponse($request);
}
/**
* Updates the password in the database.
*
* Restricted to POST method in Configuration/Backend/Routes.php
*/
public function passwordResetFinishAction(ServerRequestInterface $request): ResponseInterface
{
if (!$this->passwordReset->isEnabled()) {
return $this->redirectToLoginForm($request);
}
// Token is invalid
if (!$this->passwordReset->isValidResetTokenFromRequest($request)) {
return $this->passwordResetAction($request);
}
$this->initialize($request);
$this->initializeResetPasswordView($request);
if ($this->passwordReset->resetPassword($request, $this->context)) {
$this->view->assign('resetExecuted', true);
} else {
$this->view->assign('error', true);
}
$this->pageRenderer->setBodyContent('<body>' . $this->view->render('Login/ResetPasswordForm'));
return $this->pageRenderer->renderResponse($request);
}
private function redirectToLoginForm(ServerRequestInterface $request): ResponseInterface
{
return new RedirectResponse(
$this->uriBuilder->buildUriWithRedirect('login', [], RouteRedirect::createFromRequest($request)),
303
);
}
protected function initializeForgetPasswordView(ServerRequestInterface $request): void
{
$parameters = array_filter(['loginProvider' => $this->loginProvider]);
$this->view->assignMultiple([
'formUrl' => $this->uriBuilder->buildUriWithRedirect('password_forget_initiate_reset', $parameters, RouteRedirect::createFromRequest($request)),
'returnUrl' => $this->uriBuilder->buildUriWithRedirect('login', $parameters, RouteRedirect::createFromRequest($request)),
]);
}
protected function initializeResetPasswordView(ServerRequestInterface $request): void
{
$token = $request->getQueryParams()['t'] ?? '';
$identity = $request->getQueryParams()['i'] ?? '';
$expirationDate = $request->getQueryParams()['e'] ?? '';
$parameters = array_filter(['loginProvider' => $this->loginProvider]);
$formUrl = $this->uriBuilder->buildUriWithRedirect(
'password_reset_finish',
array_filter(array_merge($parameters, [
't' => $token,
'i' => $identity,
'e' => $expirationDate,
])),
RouteRedirect::createFromRequest($request)
);
$this->view->assignMultiple([
'token' => $token,
'identity' => $identity,
'expirationDate' => $expirationDate,
'formUrl' => $formUrl,
'restartUrl' => $this->uriBuilder->buildUriWithRedirect('password_forget', $parameters, RouteRedirect::createFromRequest($request)),
'passwordRequirements' => $this->getPasswordRequirements(),
]);
}
protected function initialize(ServerRequestInterface $request): void
{
$languageService = $this->getLanguageService();
// Only allow to execute this if not logged in as a user right now
if ($this->context->getAspect('backend.user')->isLoggedIn()) {
throw new PropagateResponseException(
new RedirectResponse($this->uriBuilder->buildUriFromRoute('login'), 303),
1618342858
);
}
// Fetch login provider from the request
$this->loginProvider = $request->getQueryParams()['loginProvider'] ?? '';
// Try to get the preferred browser language
$httpAcceptLanguage = $request->getServerParams()['HTTP_ACCEPT_LANGUAGE'] ?? '';
$preferredBrowserLanguage = $this->locales->getPreferredClientLanguage($httpAcceptLanguage);
// If we found a $preferredBrowserLanguage, which is not the default language
// initialize $this->getLanguageService() again with $preferredBrowserLanguage.
// Additionally, set the language to the backend user object, so labels in fluid views are translated
if ($preferredBrowserLanguage !== 'default') {
$languageService->init($preferredBrowserLanguage);
$this->getBackendUserAuthentication()->user['lang'] = $preferredBrowserLanguage;
}
$this->setUpBasicPageRendererForBackend($this->pageRenderer, $this->extensionConfiguration, $request, $languageService);
$this->pageRenderer->setTitle('TYPO3 CMS Login: ' . ($GLOBALS['TYPO3_CONF_VARS']['SYS']['sitename'] ?? ''));
$this->pageRenderer->loadJavaScriptModule('bootstrap');
$this->pageRenderer->loadJavaScriptModule('@typo3/backend/login.js');
$this->view = $this->backendViewFactory->create($request);
$this->view->assignMultiple([
'enablePasswordReset' => $this->passwordReset->isEnabled(),
'referrerCheckEnabled' => $this->features->isFeatureEnabled('security.backend.enforceReferrer'),
'loginUrl' => (string)$request->getUri(),
]);
$this->provideCustomLoginStyling($request);
}
protected function provideCustomLoginStyling(ServerRequestInterface $request): void
{
if (($backgroundImageStyles = $this->authenticationStyleInformation->getBackgroundImageStyles($request)) !== '') {
$this->pageRenderer->addCssInlineBlock('loginBackgroundImage', $backgroundImageStyles, null, false, true);
}
if (($footerNote = $this->authenticationStyleInformation->getFooterNote()) !== '') {
$this->view->assign('loginFootnote', $footerNote);
}
if (($highlightColorStyles = $this->authenticationStyleInformation->getHighlightColorStyles()) !== '') {
$this->pageRenderer->addCssInlineBlock('loginHighlightColor', $highlightColorStyles, null, false, true);
}
$this->view->assignMultiple([
'copyright' => $this->typo3Information->getCopyrightNotice(),
]);
}
protected function getPasswordRequirements(): array
{
$passwordPolicy = $GLOBALS['TYPO3_CONF_VARS']['BE']['passwordPolicy'] ?? 'default';
$passwordPolicyValidator = GeneralUtility::makeInstance(
PasswordPolicyValidator::class,
PasswordPolicyAction::UPDATE_USER_PASSWORD,
is_string($passwordPolicy) ? $passwordPolicy : ''
);
return $passwordPolicyValidator->getRequirements();
}
protected function getLanguageService(): LanguageService
{
return $GLOBALS['LANG'];
}
protected function getBackendUserAuthentication(): BackendUserAuthentication
{
return $GLOBALS['BE_USER'];
}
}
@@ -0,0 +1,267 @@
<?php
declare(strict_types=1);
/*
* 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 TYPO3\CMS\Backend\Controller\Resource;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Backend\Attribute\AsController;
use TYPO3\CMS\Backend\Backend\ThumbnailSize;
use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
use TYPO3\CMS\Core\Http\JsonResponse;
use TYPO3\CMS\Core\Http\RedirectResponse;
use TYPO3\CMS\Core\Http\Response;
use TYPO3\CMS\Core\Localization\LanguageService;
use TYPO3\CMS\Core\Messaging\FlashMessage;
use TYPO3\CMS\Core\Messaging\FlashMessageQueue;
use TYPO3\CMS\Core\Messaging\FlashMessageService;
use TYPO3\CMS\Core\Resource\Enum\DuplicationBehavior;
use TYPO3\CMS\Core\Resource\Exception\InsufficientFileAccessPermissionsException;
use TYPO3\CMS\Core\Resource\File;
use TYPO3\CMS\Core\Resource\Folder;
use TYPO3\CMS\Core\Resource\ProcessedFile;
use TYPO3\CMS\Core\Resource\ResourceFactory;
use TYPO3\CMS\Core\Resource\ResourceInterface;
use TYPO3\CMS\Core\SysLog\Action\File as SystemLogFileAction;
use TYPO3\CMS\Core\SysLog\Error as SystemLogErrorClassification;
use TYPO3\CMS\Core\SysLog\Type as SystemLogType;
use TYPO3\CMS\Core\Utility\File\ExtendedFileUtility;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Core\Validation\ResultException;
use TYPO3\CMS\Core\Validation\ResultRenderingTrait;
/**
* @internal
*/
#[AsController]
final readonly class ResourceController
{
use ResultRenderingTrait;
public function __construct(
private ResourceFactory $resourceFactory,
private ExtendedFileUtility $fileProcessor,
private FlashMessageService $flashMessageService,
) {}
public function gatherInformationAction(ServerRequestInterface $request): ResponseInterface
{
$identifier = $request->getQueryParams()['identifier'] ?? null;
$resource = $this->resourceFactory->retrieveFileOrFolderObject($identifier);
if ($resource === null) {
return new JsonResponse(null, 404);
}
if (!$resource->checkActionPermission('read')) {
return new JsonResponse(null, 403);
}
return new JsonResponse($this->getResourceResponseData($resource));
}
public function requestThumbnailAction(ServerRequestInterface $request): ResponseInterface
{
$identifier = $request->getQueryParams()['identifier'] ?? null;
$thumbnailSizeIdentifier = $request->getQueryParams()['size'] ?? 'default';
$keepAspectRatio = (bool)($request->getQueryParams()['keepAspectRatio'] ?? false);
$resource = null;
if ($identifier) {
$resource = $this->resourceFactory->retrieveFileOrFolderObject($identifier);
}
if ($resource === null || !($resource instanceof File && ($resource->isImage() || $resource->isMediaFile()))) {
return new Response(null, 404);
}
if (!$resource->checkActionPermission('read')) {
return new Response(null, 403);
}
$thumbnailSize = ThumbnailSize::tryFrom($thumbnailSizeIdentifier) ?? ThumbnailSize::DEFAULT;
[$width, $height] = $keepAspectRatio ? $thumbnailSize->getDimensions() : $thumbnailSize->getCroppedDimensions();
$thumbnail = $resource
->process(ProcessedFile::CONTEXT_IMAGECROPSCALEMASK, ['width' => $width, 'height' => $height]);
return new RedirectResponse(
GeneralUtility::locationHeaderUrl($thumbnail->getPublicUrl() ?? '', $request)
);
}
public function renameResourceAction(ServerRequestInterface $request): ResponseInterface
{
$identifier = $request->getParsedBody()['identifier'] ?? null;
$origin = null;
if ($identifier) {
$origin = $this->resourceFactory->retrieveFileOrFolderObject($identifier);
}
try {
if (!$origin instanceof File && !$origin instanceof Folder) {
throw new \InvalidArgumentException('Resource must be a file or a folder', 1676979120);
}
if ($origin->getStorage()->isFallbackStorage()) {
throw new InsufficientFileAccessPermissionsException('You are not allowed to access files outside your storages', 1676299579);
}
if (!$origin->checkActionPermission('rename')) {
throw new InsufficientFileAccessPermissionsException('You are not allowed to rename the resource', 1676979130);
}
$resourceName = $request->getParsedBody()['resourceName'] ?? null;
if (!$resourceName || trim((string)$resourceName) === '') {
throw new \InvalidArgumentException('The resource name cannot be empty', 1676978732);
}
$oldName = $origin->getName();
if ($oldName === $resourceName) {
$message = sprintf($this->getLanguageService()->sL('LLL:EXT:backend/Resources/Private/Language/locallang_resource.xlf:ajax.error.message.resourceNameNotDifferent'), $oldName);
return new JsonResponse($this->getResponseData(true, $message, $origin));
}
$resource = $origin->rename($resourceName);
if ($resource->getName() === $oldName) {
$message = sprintf($this->getLanguageService()->sL('LLL:EXT:backend/Resources/Private/Language/locallang_resource.xlf:ajax.error.message.resourceNotRenamed'), $oldName);
return new JsonResponse($this->getResponseData(false, $message, $origin));
}
} catch (ResultException $exception) {
// Possible Exception thrown within the `->rename(...)` chain via ResourceConsistencyService
return new JsonResponse($this->getResponseData(false, $this->renderResultException($exception, $this->getLanguageService())));
} catch (\Exception $exception) {
$message = match ($exception->getCode()) {
1676979120 => $this->getLanguageService()->sL('LLL:EXT:backend/Resources/Private/Language/locallang_resource.xlf:ajax.error.message.resourceNotFileOrFolder'),
1676299579 => $this->getLanguageService()->sL('LLL:EXT:backend/Resources/Private/Language/locallang_resource.xlf:ajax.error.message.resourceOutsideOfStorages'),
1676979130 => $this->getLanguageService()->sL('LLL:EXT:backend/Resources/Private/Language/locallang_resource.xlf:ajax.error.message.resourceNoPermissionRename'),
1676978732 => $this->getLanguageService()->sL('LLL:EXT:backend/Resources/Private/Language/locallang_resource.xlf:ajax.error.message.resourceNameCannotBeEmpty'),
default => $exception->getMessage(),
};
return new JsonResponse($this->getResponseData(false, $message));
}
return new JsonResponse($this->getResponseData(
true,
sprintf(
$this->getLanguageService()->sL('LLL:EXT:backend/Resources/Private/Language/locallang_resource.xlf:ajax.success.message.renamed'),
$oldName,
$resource->getName()
),
$origin,
$resource,
));
}
public function replaceResourceAction(ServerRequestInterface $request): ResponseInterface
{
$uploadedFiles = $request->getUploadedFiles();
if ($uploadedFiles === []) {
return new JsonResponse($this->getResponseData(
false,
$this->getLanguageService()->sL('LLL:EXT:backend/Resources/Private/Language/locallang_resource.xlf:ajax.error.message.resourceNotAvailableToUpload'),
));
}
$uid = $request->getParsedBody()['uid'];
$keepFilename = (bool)($request->getParsedBody()['keepFilename'] ?? false);
$origin = $this->resourceFactory->retrieveFileOrFolderObject($uid);
if ($origin === null) {
return new JsonResponse($this->getResponseData(
false,
$this->getLanguageService()->sL('LLL:EXT:backend/Resources/Private/Language/locallang_resource.xlf:ajax.error.message.resourceNotFound'),
));
}
$this->fileProcessor->setActionPermissions();
$this->fileProcessor->setExistingFilesConflictMode(DuplicationBehavior::REPLACE);
$this->fileProcessor->start([
'replace' => [
1 => [
'data' => 1,
'uid' => $uid,
'keepFilename' => $keepFilename,
],
],
], $uploadedFiles);
$result = $this->fileProcessor->processData();
$flashMessageQueue = $this->flashMessageService->getMessageQueueByIdentifier();
$messages = implode("\n", array_map(static fn(FlashMessage $message) => $message->getMessage(), $flashMessageQueue->getAllMessagesAndFlush()));
/** @var File|null $fileReplacement */
$fileReplacement = $result['replace'][0][0] ?? null;
if ($fileReplacement === null) {
return new JsonResponse($this->getResponseData(
false,
$messages,
$origin
));
}
return new JsonResponse($this->getResponseData(
true,
$messages,
$origin,
$fileReplacement
));
}
/**
* Prepare response data for a JSON response
*/
private function getResponseData(bool $success, string $message, ?ResourceInterface $origin = null, ?ResourceInterface $resource = null): array
{
$flashMessageQueue = new FlashMessageQueue('backend');
$flashMessageQueue->enqueue(
new FlashMessage(
$message,
$this->getLanguageService()->sL('LLL:EXT:backend/Resources/Private/Language/locallang_resource.xlf:ajax.' . ($success ? 'success' : 'error'))
)
);
// Next to the flash message, also log the action to be consistent with the use in ExtendedFileUtiltiy
$this->getBackendUser()->writelog(SystemLogType::FILE, SystemLogFileAction::RENAME, $success ? SystemLogErrorClassification::MESSAGE : SystemLogErrorClassification::USER_ERROR, null, $message, []);
return [
'success' => $success,
'status' => $flashMessageQueue,
'origin' => $this->getResourceResponseData($origin),
'resource' => $this->getResourceResponseData($resource),
];
}
/**
* Prepare resource data for a JSON response
*/
private function getResourceResponseData(?ResourceInterface $resource): ?array
{
if (!$resource) {
return null;
}
return [
'type' => $resource instanceof File ? 'file' : 'folder',
'identifier' => $resource instanceof File || $resource instanceof Folder ? $resource->getCombinedIdentifier() : null,
'name' => $resource->getName(),
'hasPreview' => $resource instanceof File && ($resource->isImage() || $resource->isMediaFile()),
'uid' => $resource instanceof File ? $resource->getUid() : null,
'metaUid' => $resource instanceof File ? $resource->getMetaData()->offsetGet('uid') : null,
'createdAt' => $resource instanceof File ? $resource->getCreationTime() : null,
'size' => $resource instanceof File ? $resource->getSize() : null,
];
}
private function getBackendUser(): BackendUserAuthentication
{
return $GLOBALS['BE_USER'];
}
private function getLanguageService(): LanguageService
{
return $GLOBALS['LANG'];
}
}
@@ -0,0 +1,250 @@
<?php
declare(strict_types=1);
/*
* 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 TYPO3\CMS\Backend\Controller\Security;
use Psr\EventDispatcher\EventDispatcherInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\UriInterface;
use Psr\Log\LoggerInterface;
use TYPO3\CMS\Backend\Attribute\AsController;
use TYPO3\CMS\Backend\Routing\UriBuilder;
use TYPO3\CMS\Backend\Security\SudoMode\Access\AccessClaim;
use TYPO3\CMS\Backend\Security\SudoMode\Access\AccessFactory;
use TYPO3\CMS\Backend\Security\SudoMode\Access\AccessStorage;
use TYPO3\CMS\Backend\Security\SudoMode\Event\SudoModeVerifyEvent;
use TYPO3\CMS\Backend\Security\SudoMode\Exception\RequestGrantedException;
use TYPO3\CMS\Backend\Security\SudoMode\PasswordVerification;
use TYPO3\CMS\Backend\Template\ModuleTemplateFactory;
use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
use TYPO3\CMS\Core\Context\Context;
use TYPO3\CMS\Core\Crypto\HashAlgo;
use TYPO3\CMS\Core\Crypto\HashService;
use TYPO3\CMS\Core\Http\JsonResponse;
use TYPO3\CMS\Core\Http\RedirectResponse;
use TYPO3\CMS\Core\Localization\LanguageService;
use TYPO3\CMS\Core\Page\PageRenderer;
use TYPO3\CMS\Core\Routing\BackendEntryPointResolver;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Handling visual sudo mode verification for configured routes/modules.
*
* @internal
*/
#[AsController]
final readonly class SudoModeController
{
private const string ROUTE_PATH_MODULE = '/sudo-mode/module';
private const string ROUTE_PATH_APPLY = '/sudo-mode/apply';
private const string ROUTE_PATH_ERROR = '/sudo-mode/error';
private const string ROUTE_PATH_VERIFY = '/ajax/sudo-mode/verify';
public function __construct(
private PageRenderer $pageRenderer,
private UriBuilder $uriBuilder,
private AccessFactory $factory,
private AccessStorage $storage,
private PasswordVerification $passwordVerification,
private ModuleTemplateFactory $moduleTemplateFactory,
private BackendEntryPointResolver $backendEntryPointResolver,
private EventDispatcherInterface $eventDispatcher,
private HashService $hashService,
private LoggerInterface $logger,
) {}
public function buildModuleActionUriForClaim(AccessClaim $claim): UriInterface
{
return $this->uriBuilder->buildUriFromRoutePath(
self::ROUTE_PATH_MODULE,
$this->buildUriParametersForClaim($claim, 'module')
);
}
public function buildVerifyActionUriForClaim(AccessClaim $claim): UriInterface
{
return $this->uriBuilder->buildUriFromRoutePath(
self::ROUTE_PATH_VERIFY,
$this->buildUriParametersForClaim($claim, 'verify')
);
}
/**
* Renders the module backend markup, including the `<typo3-backend-security-sudo-mode>` element.
*/
public function moduleAction(ServerRequestInterface $request): ResponseInterface
{
$this->pageRenderer->getJavaScriptRenderer()->addGlobalAssignment([
'TYPO3' => [
'configuration' => [
'username' => htmlspecialchars($this->getBackendUser()->user['username']),
],
],
]);
$claim = $this->resolveClaimFromRequest($request, 'module');
if ($claim === null) {
return $this->redirectToErrorAction();
}
$view = $this->moduleTemplateFactory->create($request);
$view->assignMultiple([
'verifyActionUri' => $this->buildVerifyActionUriForClaim($claim),
'allowInstallToolPassword' => $this->getBackendUser()->isSystemMaintainer(),
'labels' => $this->getLanguageService()->getLabelsFromResource('EXT:backend/Resources/Private/Language/SudoMode.xlf'),
]);
return $view->renderResponse('SudoMode/Module');
}
/**
* Called from JavaScript web-component, throwing an exception that is handled by `SudoModeInterceptor` middleware.
*/
public function applyAction(ServerRequestInterface $request): ResponseInterface
{
// @todo security: action is not signed and can be by-passed easily
$claim = $this->resolveClaimFromRequest($request, 'apply');
if ($claim === null) {
return $this->redirectToErrorAction();
}
$this->storage->removeClaim($claim);
throw (new RequestGrantedException('Replay request', 1605873757))
->withInstruction($claim->instruction);
}
/**
* Renders markup with error messages in case `AccessClaim` could not be resolved (e.g. when expired).
*/
public function errorAction(ServerRequestInterface $request): ResponseInterface
{
$view = $this->moduleTemplateFactory->create($request);
$view->assignMultiple([
'cancelUri' => $this->backendEntryPointResolver->getPathFromRequest($request),
'cancelTarget' => '_top',
'labels' => $this->getLanguageService()->getLabelsFromResource('EXT:backend/Resources/Private/Language/SudoMode.xlf'),
]);
return $view->renderResponse('SudoMode/Error');
}
/**
* Verifies the provided password, called via AJAX from JavaScript web-component.
*/
public function verifyAction(ServerRequestInterface $request): ResponseInterface
{
$claim = $this->resolveClaimFromRequest($request, 'verify');
if ($claim === null) {
return new JsonResponse(['message' => 'bad-request'], 400);
}
$password = (string)($request->getParsedBody()['password'] ?? '');
$useInstallToolPassword = (bool)($request->getParsedBody()['useInstallToolPassword'] ?? false);
// Only system maintainers are allowed to use the installtool password for sudo mode operations
if (!$this->getBackendUser()->isSystemMaintainer()) {
$useInstallToolPassword = false;
}
$loggerContext = $this->buildLoggerContext($claim);
$redirect = [
'uri' => (string)$this->uriBuilder->buildUriFromRoutePath(
self::ROUTE_PATH_APPLY,
$this->buildUriParametersForClaim($claim, 'apply')
),
];
$event = $this->eventDispatcher->dispatch(new SudoModeVerifyEvent($claim, $password, $useInstallToolPassword));
if ($event->isVerified()) {
$this->logger->info('Passed by PSR-14 SudoModeVerifyEvent', $loggerContext);
$this->grantClaim($claim);
return new JsonResponse(['message' => 'accessGranted', 'redirect' => $redirect]);
}
if ($useInstallToolPassword && $this->passwordVerification->verifyInstallToolPassword($password)) {
$this->logger->info('Verified with install tool password', $loggerContext);
$this->grantClaim($claim);
return new JsonResponse(['message' => 'accessGranted', 'redirect' => $redirect]);
}
if (!$useInstallToolPassword && $this->passwordVerification->verifyBackendUserPassword($password, $this->getBackendUser())) {
$this->logger->info('Verified with user password', $loggerContext);
$this->grantClaim($claim);
return new JsonResponse(['message' => 'accessGranted', 'redirect' => $redirect]);
}
return new JsonResponse(['message' => 'invalidPassword'], 403);
}
private function redirectToErrorAction(): ResponseInterface
{
$uri = $this->uriBuilder->buildUriFromRoutePath(self::ROUTE_PATH_ERROR);
return new RedirectResponse($uri);
}
/**
* @param string $additionalPepper used to create a specific signature (e.g. on the action)
*/
private function buildUriParametersForClaim(AccessClaim $claim, string $additionalPepper): array
{
$additionalPeppers = [self::class, $additionalPepper];
return [
'claim' => $claim->id,
'hash' => $this->hashService->hmac($claim->id, json_encode($additionalPeppers), HashAlgo::SHA3_256),
];
}
/**
* @param string $additionalPepper used to create a specific signature (e.g. on the action)
*/
private function resolveClaimFromRequest(ServerRequestInterface $request, string $additionalPepper): ?AccessClaim
{
$claimId = (string)($request->getQueryParams()['claim'] ?? '');
$claimHash = (string)($request->getQueryParams()['hash'] ?? '');
$additionalPeppers = [self::class, $additionalPepper];
$expectedHash = $this->hashService->hmac($claimId, json_encode($additionalPeppers), HashAlgo::SHA3_256);
if ($claimId === '' || $claimHash === '' || !hash_equals($expectedHash, $claimHash)) {
return null;
}
return $this->storage->findClaimById($claimId);
}
private function grantClaim(AccessClaim $claim): void
{
foreach ($claim->subjects as $subject) {
$grant = $this->factory->buildGrantForSubject($subject);
$this->storage->addGrant($grant);
}
}
/**
* @return array<string, int|string>
*/
private function buildLoggerContext(AccessClaim $claim): array
{
$backendUserAspect = GeneralUtility::makeInstance(Context::class)
->getAspect('backend.user');
return [
'claim' => $claim->id,
'user' => $backendUserAspect->get('id'),
];
}
private function getBackendUser(): BackendUserAuthentication
{
return $GLOBALS['BE_USER'];
}
private function getLanguageService(): LanguageService
{
return $GLOBALS['LANG'];
}
}
@@ -0,0 +1,704 @@
<?php
declare(strict_types=1);
/*
* 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 TYPO3\CMS\Backend\Controller;
use Psr\EventDispatcher\EventDispatcherInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Backend\Attribute\AsController;
use TYPO3\CMS\Backend\Event\AddUserSettingsJavaScriptModulesEvent;
use TYPO3\CMS\Backend\Form\FormDataCompiler;
use TYPO3\CMS\Backend\Form\FormDataGroup\UserSettingsDataGroup;
use TYPO3\CMS\Backend\Form\FormResultFactory;
use TYPO3\CMS\Backend\Form\FormResultHandler;
use TYPO3\CMS\Backend\Form\NodeFactory;
use TYPO3\CMS\Backend\Module\ModuleProvider;
use TYPO3\CMS\Backend\Routing\UriBuilder;
use TYPO3\CMS\Backend\Template\Components\ButtonBar;
use TYPO3\CMS\Backend\Template\Components\ComponentFactory;
use TYPO3\CMS\Backend\Template\Enum\ModuleLayout;
use TYPO3\CMS\Backend\Template\ModuleTemplate;
use TYPO3\CMS\Backend\Template\ModuleTemplateFactory;
use TYPO3\CMS\Backend\Utility\BackendUtility;
use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
use TYPO3\CMS\Core\Authentication\Mfa\MfaProviderRegistry;
use TYPO3\CMS\Core\Authentication\UserSettingsSchema;
use TYPO3\CMS\Core\Database\Connection;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\DataHandling\DataHandler;
use TYPO3\CMS\Core\FormProtection\FormProtectionFactory;
use TYPO3\CMS\Core\Imaging\IconFactory;
use TYPO3\CMS\Core\Imaging\IconSize;
use TYPO3\CMS\Core\Information\Typo3Information;
use TYPO3\CMS\Core\Localization\DateFormatter;
use TYPO3\CMS\Core\Localization\LanguageService;
use TYPO3\CMS\Core\Localization\LanguageServiceFactory;
use TYPO3\CMS\Core\Localization\Locales;
use TYPO3\CMS\Core\Page\PageRenderer;
use TYPO3\CMS\Core\PasswordPolicy\Event\EnrichPasswordValidationContextDataEvent;
use TYPO3\CMS\Core\PasswordPolicy\PasswordPolicyAction;
use TYPO3\CMS\Core\PasswordPolicy\PasswordPolicyValidator;
use TYPO3\CMS\Core\PasswordPolicy\Validator\Dto\ContextData;
use TYPO3\CMS\Core\Resource\Exception\FileDoesNotExistException;
use TYPO3\CMS\Core\Resource\ResourceFactory;
use TYPO3\CMS\Core\Schema\TcaSchemaBuilder;
use TYPO3\CMS\Core\SysLog\Action\Setting as SystemLogSettingAction;
use TYPO3\CMS\Core\SysLog\Error as SystemLogErrorClassification;
use TYPO3\CMS\Core\SysLog\Type as SystemLogType;
use TYPO3\CMS\Core\Type\ContextualFeedbackSeverity;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Script class for the User Settings module
*
* @internal This is a specific Backend Controller implementation and is not considered part of the Public TYPO3 API.
*/
#[AsController]
class SetupModuleController
{
protected const DISALLOWED_FIELD_NAMES = ['password', 'password2', 'email', 'realName', 'admin', 'avatar'];
protected const PASSWORD_NOT_UPDATED = 0;
protected const PASSWORD_UPDATED = 1;
protected const PASSWORD_NOT_THE_SAME = 2;
// @todo: Can this constant be removed?
protected const PASSWORD_OLD_WRONG = 3;
protected const PASSWORD_POLICY_FAILED = 4;
protected array $overrideConf = [];
protected bool $languageUpdate = false;
protected array $persistentUpdate = [];
protected bool $pagetreeNeedsRefresh = false;
protected bool $colorSchemeChanged = false;
protected bool $themeChanged = false;
protected bool $backendTitleFormatChanged = false;
protected bool $dateTimeFirstDayOfWeekChanged = false;
protected array $tsFieldConf = [];
protected int $passwordIsUpdated = self::PASSWORD_NOT_UPDATED;
protected bool $passwordIsSubmitted = false;
protected bool $setupIsUpdated = false;
protected bool $settingsAreResetToDefault = false;
protected PasswordPolicyValidator $passwordPolicyValidator;
public function __construct(
protected readonly Typo3Information $typo3Information,
protected readonly EventDispatcherInterface $eventDispatcher,
protected readonly MfaProviderRegistry $mfaProviderRegistry,
protected readonly IconFactory $iconFactory,
protected readonly PageRenderer $pageRenderer,
protected readonly ModuleTemplateFactory $moduleTemplateFactory,
protected readonly LanguageServiceFactory $languageServiceFactory,
protected readonly ModuleProvider $moduleProvider,
protected readonly UriBuilder $uriBuilder,
protected readonly FormProtectionFactory $formProtectionFactory,
protected readonly Locales $locales,
protected readonly ComponentFactory $componentFactory,
protected readonly DateFormatter $dateFormatter,
protected readonly FormDataCompiler $formDataCompiler,
protected readonly NodeFactory $nodeFactory,
protected readonly FormResultFactory $formResultFactory,
protected readonly FormResultHandler $formResultHandler,
protected readonly UserSettingsSchema $userSettingsSchema,
protected readonly TcaSchemaBuilder $tcaSchemaBuilder,
protected readonly ResourceFactory $resourceFactory,
) {
$passwordPolicy = $GLOBALS['TYPO3_CONF_VARS']['BE']['passwordPolicy'] ?? 'default';
$action = PasswordPolicyAction::UPDATE_USER_PASSWORD;
if ($this->getBackendUser()->getOriginalUserIdWhenInSwitchUserMode()) {
$action = PasswordPolicyAction::UPDATE_USER_PASSWORD_SWITCH_USER_MODE;
}
$this->passwordPolicyValidator = GeneralUtility::makeInstance(
PasswordPolicyValidator::class,
$action,
is_string($passwordPolicy) ? $passwordPolicy : ''
);
}
/**
* Injects the request object, checks if data should be saved, and prepares a HTML page
*/
public function mainAction(ServerRequestInterface $request): ResponseInterface
{
$view = $this->initialize($request);
$this->storeIncomingData($request);
if ($this->pagetreeNeedsRefresh || $this->settingsAreResetToDefault) {
BackendUtility::setUpdateSignal('updatePageTree');
}
if ($this->colorSchemeChanged || $this->settingsAreResetToDefault) {
BackendUtility::setUpdateSignal('updateColorScheme', $this->getBackendUser()->uc['colorScheme'] ?? 'auto');
}
if ($this->themeChanged || $this->settingsAreResetToDefault) {
BackendUtility::setUpdateSignal('updateTheme', $this->getBackendUser()->uc['theme'] ?? 'modern');
}
if ($this->backendTitleFormatChanged || $this->settingsAreResetToDefault) {
BackendUtility::setUpdateSignal('updateTitleFormat', $this->getBackendUser()->uc['backendTitleFormat'] ?? 'titleFirst');
}
if ($this->dateTimeFirstDayOfWeekChanged || $this->settingsAreResetToDefault) {
BackendUtility::setUpdateSignal('updateDateTimeFirstDayOfWeek', $this->getBackendUser()->uc['dateTimeFirstDayOfWeek'] ?? '');
}
if ($this->languageUpdate) {
$this->getLanguageService()->init($this->getBackendUser()->user['lang'] ?? 'en');
$locale = $this->getLanguageService()->getLocale();
if ($locale !== null) {
$parameters = [
'language' => $locale->getLanguageCode(),
];
BackendUtility::setUpdateSignal('updateBackendLanguage', $parameters);
}
}
if ($this->persistentUpdate !== []) {
foreach ($this->persistentUpdate as $params) {
BackendUtility::setUpdateSignal('updatePersistent', $params);
}
}
// Use FormEngine to render the user settings form
$formData = $this->compileFormData($request, $this->userSettingsSchema->getTca());
$formData['renderType'] = 'fullRecordContainer';
$formResultArray = $this->nodeFactory->create($formData)->render();
$formResult = $this->formResultFactory->create($formResultArray);
$this->formResultHandler->addAssets($formResult);
$formProtection = $this->formProtectionFactory->createFromRequest($request);
$this->addFlashMessages($view);
$view->addButtonToButtonBar($this->componentFactory->createSaveButton('SetupModuleController')->setName('data[save]'));
$this->registerResetButtonToButtonBar($view);
// Set shortcut context - reload button is added automatically
$view->getDocHeaderComponent()->setShortcutContext(
'user_setup',
$this->getLanguageService()->translate('short_description', 'backend.modules.user_settings')
);
$view->assignMultiple([
'typo3Info' => $this->typo3Information,
'isLanguageUpdate' => $this->languageUpdate,
'formEngineHtml' => $formResult->html,
'formToken' => $formProtection->generateToken('BE user setup', 'edit'),
]);
return $view->renderResponse('Setup/Main');
}
/**
* Compile form data for FormEngine rendering.
*/
protected function compileFormData(ServerRequestInterface $request, array $userSettingsTca): array
{
$backendUser = $this->getBackendUser();
$formDataCompilerInput = [
'request' => $request,
'tableName' => 'be_users_settings',
'vanillaUid' => (int)$backendUser->user['uid'],
'command' => 'edit',
'returnUrl' => '',
'tcaSchemata' => $this->tcaSchemaBuilder->buildFromStructure($userSettingsTca),
'fullTca' => $userSettingsTca,
];
return $this->formDataCompiler->compile(
$formDataCompilerInput,
GeneralUtility::makeInstance(UserSettingsDataGroup::class)
);
}
/**
* Initializes the module for display of the settings form.
*/
protected function initialize(ServerRequestInterface $request): ModuleTemplate
{
$languageService = $this->getLanguageService();
$backendUser = $this->getBackendUser();
$view = $this->moduleTemplateFactory->create($request);
$this->pageRenderer->loadJavaScriptModule('@typo3/backend/modal.js');
$this->pageRenderer->loadJavaScriptModule('@typo3/backend/form-engine.js');
$this->pageRenderer->loadJavaScriptModule('@typo3/backend/setup-module.js');
$this->processAdditionalJavaScriptModules($request);
$this->pageRenderer->addInlineSetting('FormEngine', 'formName', 'editform');
$this->pageRenderer->addInlineLanguageLabelArray([
'FormEngine.remainingCharacters' => $languageService->translate('labels.remainingCharacters', 'core.core'),
]);
$view->setTitle($languageService->translate('user_settings', 'backend.user_profile'));
$view->setLayout(ModuleLayout::NORMAL);
// Getting the 'override' values as set might be set in user TSconfig
$this->overrideConf = $backendUser->getTSConfig()['setup.']['override.'] ?? [];
// Getting the disabled fields might be set in user TSconfig (eg setup.fields.password.disabled=1)
$this->tsFieldConf = $backendUser->getTSConfig()['setup.']['fields.'] ?? [];
// if password is disabled, disable repeat of password too (password2)
if ($this->tsFieldConf['password.']['disabled'] ?? false) {
$this->tsFieldConf['password2.']['disabled'] = 1;
}
return $view;
}
protected function processAdditionalJavaScriptModules(ServerRequestInterface $request): void
{
$event = new AddUserSettingsJavaScriptModulesEvent($request);
$event = $this->eventDispatcher->dispatch($event);
foreach ($event->getJavaScriptModules() as $specifier) {
$this->pageRenderer->loadJavaScriptModule($specifier);
}
}
/**
* If settings are submitted via POST, store them
*/
protected function storeIncomingData(ServerRequestInterface $request): void
{
$postData = $request->getParsedBody();
if (!is_array($postData) || empty($postData)) {
return;
}
$formProtection = $this->formProtectionFactory->createFromRequest($request);
// Separate submitted data into corresponding partitions
$backendUserId = (int)$this->getBackendUser()->user['uid'];
$beUsersSubmission = $this->extractPartitionData($postData['data']['be_users_settings'][$backendUserId] ?? [], 'be_users');
$userSettingsSubmission = $this->extractPartitionData($postData['data']['be_users_settings'][$backendUserId] ?? [], 'user_settings');
$columns = $this->userSettingsSchema->getColumns();
$backendUser = $this->getBackendUser();
$beUserId = (int)$backendUser->user['uid'];
$storeRec = [];
$doSaveData = false;
$fieldList = $this->getFieldsFromShowItem();
if ($beUsersSubmission !== []
&& $userSettingsSubmission !== []
&& $formProtection->validateToken((string)($postData['formToken'] ?? ''), 'BE user setup', 'edit')
) {
// UC hashed before applying changes
$save_before = md5(serialize($backendUser->uc));
// PUT SETTINGS into the ->uc array:
// Reload left frame when switching BE language
if (isset($beUsersSubmission['lang']) && $beUsersSubmission['lang'] !== $backendUser->user['lang']) {
$this->languageUpdate = true;
}
// Reload pagetree if the title length is changed
if (isset($userSettingsSubmission['titleLen']) && $userSettingsSubmission['titleLen'] !== $backendUser->uc['titleLen']) {
$this->pagetreeNeedsRefresh = true;
}
if (isset($userSettingsSubmission['colorScheme']) && $userSettingsSubmission['colorScheme'] !== ($backendUser->uc['colorScheme'] ?? null)) {
$this->colorSchemeChanged = true;
}
if (isset($userSettingsSubmission['theme']) && $userSettingsSubmission['theme'] !== ($backendUser->uc['theme'] ?? null)) {
$this->themeChanged = true;
}
if (isset($userSettingsSubmission['backendTitleFormat']) && $userSettingsSubmission['backendTitleFormat'] !== ($backendUser->uc['backendTitleFormat'] ?? null)) {
$this->backendTitleFormatChanged = true;
}
if (isset($userSettingsSubmission['dateTimeFirstDayOfWeek']) && $userSettingsSubmission['dateTimeFirstDayOfWeek'] !== ($backendUser->uc['dateTimeFirstDayOfWeek'] ?? null)) {
$this->dateTimeFirstDayOfWeekChanged = true;
$this->persistentUpdate[] = [
'fieldName' => 'dateTimeFirstDayOfWeek',
'value' => $userSettingsSubmission['dateTimeFirstDayOfWeek'],
];
}
// Options which should trigger direct JS persistent update, because
// their new state needs to be available in JS components right away.
foreach ($this->userSettingsSchema->getPersistentUpdateFieldNames() as $fieldName) {
$fieldValue = ((int)($userSettingsSubmission[$fieldName] ?? 0)) ? 1 : 0;
if ($fieldValue !== ($backendUser->uc[$fieldName] ?? null)) {
$this->persistentUpdate[] = [
'fieldName' => $fieldName,
'value' => $fieldValue ? '1' : '0',
];
}
}
if ($postData['data']['setValuesToDefault'] ?? false) {
// If every value should be default
$backendUser->resetUC();
$this->settingsAreResetToDefault = true;
} elseif ($postData['data']['save'] ?? false) {
foreach ($columns as $field => $config) {
if (!in_array($field, $fieldList, true)) {
continue;
}
// Skip any disallowed field name, not matter if it's in be_users or user_settings partition
if (in_array($field, self::DISALLOWED_FIELD_NAMES, true)) {
continue;
}
$isBeUsersField = ($config['table'] ?? '') === 'be_users';
$fieldType = $config['type'] ?? 'text';
if ($isBeUsersField) {
$submittedValue = $beUsersSubmission[$field] ?? null;
if (!isset($config['access']) || ($this->checkAccess($config) && ($backendUser->user[$field] !== $submittedValue))) {
if ($fieldType === 'check') {
$fieldValue = (int)($submittedValue ?? 0);
} else {
$fieldValue = $submittedValue;
}
$storeRec['be_users'][$beUserId][$field] = $fieldValue;
$backendUser->user[$field] = $fieldValue;
}
} else {
if ($fieldType === 'check') {
$backendUser->uc[$field] = (int)($userSettingsSubmission[$field] ?? 0);
} else {
$backendUser->uc[$field] = htmlspecialchars($userSettingsSubmission[$field] ?? '');
}
}
}
// Personal data for the users be_user-record (email, name, password...)
// If email and name is changed, set it in the users record:
$be_user_data = $beUsersSubmission;
// Temporarily hold `password2` for the hook to be able to adjust the password
$be_user_data['password2'] = $userSettingsSubmission['password2'] ?? '';
// Possibility to modify the transmitted values. Useful to do transformations, like RSA password decryption
foreach ($GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['ext/setup/mod/index.php']['modifyUserDataBeforeSave'] ?? [] as $function) {
$params = ['be_user_data' => &$be_user_data];
GeneralUtility::callUserFunction($function, $params, $this);
}
$this->passwordIsSubmitted = (string)($be_user_data['password'] ?? '') !== '';
$passwordIsConfirmed = $this->passwordIsSubmitted && $be_user_data['password'] === $be_user_data['password2'];
unset($be_user_data['password2']);
// Validate password against password policy
$contextData = new ContextData(
loginMode: 'BE',
currentPasswordHash: $this->getBackendUser()->user['password'],
newUserFullName: $be_user_data['realName'] ?? $this->getBackendUser()->user['realName']
);
$contextData->setData('currentUsername', $this->getBackendUser()->user['username']);
$event = $this->eventDispatcher->dispatch(
new EnrichPasswordValidationContextDataEvent(
$contextData,
$be_user_data,
self::class
)
);
$contextData = $event->getContextData();
$passwordValid = true;
if ($passwordIsConfirmed
&& !$this->passwordPolicyValidator->isValidPassword($be_user_data['password'], $contextData)
) {
$passwordValid = false;
$this->passwordIsUpdated = self::PASSWORD_POLICY_FAILED;
}
// Update the real name:
if (isset($be_user_data['realName']) && $be_user_data['realName'] !== $backendUser->user['realName']) {
$backendUser->user['realName'] = ($storeRec['be_users'][$beUserId]['realName'] = substr($be_user_data['realName'], 0, 80));
}
// Update the email address:
if (isset($be_user_data['email']) && $be_user_data['email'] !== $backendUser->user['email']) {
$backendUser->user['email'] = ($storeRec['be_users'][$beUserId]['email'] = substr($be_user_data['email'], 0, 255));
}
// Update the password:
if ($this->passwordIsSubmitted) {
if ($passwordIsConfirmed && $passwordValid) {
$this->passwordIsUpdated = self::PASSWORD_UPDATED;
$storeRec['be_users'][$beUserId]['password'] = $be_user_data['password'];
} elseif ($passwordIsConfirmed) {
$this->passwordIsUpdated = self::PASSWORD_POLICY_FAILED;
} else {
$this->passwordIsUpdated = self::PASSWORD_NOT_THE_SAME;
}
}
$this->setAvatarFileUid($beUserId, $be_user_data['avatar'] ?? null, $storeRec);
$doSaveData = true;
}
// Explicitly unset disallowed field names
foreach (self::DISALLOWED_FIELD_NAMES as $disallowedFieldName) {
unset($backendUser->uc[$disallowedFieldName]);
}
// Inserts the overriding values.
$backendUser->overrideUC();
$save_after = md5(serialize($backendUser->uc));
// If something in the uc-array of the user has changed, we save the array...
if ($save_before != $save_after) {
$backendUser->writeUC();
$backendUser->writelog(SystemLogType::SETTING, SystemLogSettingAction::CHANGE, SystemLogErrorClassification::MESSAGE, null, 'Personal settings changed', []);
$this->setupIsUpdated = true;
}
// Persist data if something has changed:
if (!empty($storeRec) && $doSaveData) {
// Set user to admin to circumvent DataHandler restrictions.
// Not using isAdmin() to fetch the original value, just in case it has been boolean casted.
$savedUserAdminState = $backendUser->user['admin'];
$backendUser->user['admin'] = true;
// Make dedicated instance of TCE for storing the changes.
$dataHandler = GeneralUtility::makeInstance(DataHandler::class);
$dataHandler->start($storeRec, [], $backendUser);
$dataHandler->process_datamap();
$dataHandler->printLogErrorMessages();
// reset the user record admin flag to previous value, just in case it gets used any further.
$backendUser->user['admin'] = $savedUserAdminState;
if ($this->passwordIsUpdated === self::PASSWORD_NOT_UPDATED || count($storeRec['be_users'][$beUserId]) > 1) {
$this->setupIsUpdated = true;
}
BackendUtility::setUpdateSignal('updateTopbar');
}
}
}
/**
* Returns access check (currently only "admin" is supported)
*
* @param array $config Configuration of the field, access mode is defined in key 'access'
* @return bool Whether it is allowed to modify the given field
*/
protected function checkAccess(array $config)
{
$access = $config['access'];
if (isset($GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['setup']['accessLevelCheck'][$access])) {
if (class_exists($access)) {
$accessObject = GeneralUtility::makeInstance($access);
if (method_exists($accessObject, 'accessLevelCheck')) {
// Initialize vars. If method fails, $set will be set to FALSE
return $accessObject->accessLevelCheck($config);
}
}
} elseif ($access === 'admin') {
return $this->getBackendUser()->isAdmin();
}
return false;
}
/**
* Returns array with fields defined in TCA user settings showitem.
* Remove fields which are disabled by user TSconfig
*
* @return list<string> Array with field names visible in form
*/
protected function getFieldsFromShowItem(): array
{
// just keep field names, filter out control sequences
$tcaFieldNames = array_keys($this->userSettingsSchema->getColumns());
$allowedFields = GeneralUtility::trimExplode(',', $this->userSettingsSchema->getRawShowitem(), true);
$allowedFields = array_map($this->extractShowitemFieldName(...), $allowedFields);
$allowedFields = array_filter($allowedFields, static fn(string $field): bool => in_array($field, $tcaFieldNames, true));
$backendUser = $this->getBackendUser();
if ($backendUser->getOriginalUserIdWhenInSwitchUserMode() && $backendUser->isSystemMaintainer(true)) {
// DataHandler denies changing the password of system maintainer users in switch user mode.
// Do not show the password fields is this case.
$key = array_search('password', $allowedFields);
if ($key !== false) {
unset($allowedFields[$key]);
}
$key = array_search('password2', $allowedFields);
if ($key !== false) {
unset($allowedFields[$key]);
}
}
foreach ($this->tsFieldConf as $fieldName => $userTsFieldConfig) {
if (!empty($userTsFieldConfig['disabled'])) {
$fieldName = rtrim($fieldName, '.');
$key = array_search($fieldName, $allowedFields);
if ($key !== false) {
unset($allowedFields[$key]);
}
}
}
return $allowedFields;
}
/**
* Extracts `field_name` from a showitem field `field_name;field_label`.
*/
protected function extractShowitemFieldName(string $fieldName): string
{
$offset = strpos($fieldName, ';');
return $offset !== false ? substr($fieldName, 0, $offset) : $fieldName;
}
/**
* Get Avatar fileUid
*/
protected function getAvatarFileUid(int $beUserId): int
{
$queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('sys_file_reference');
$file = $queryBuilder
->select('uid_local')
->from('sys_file_reference')
->where(
$queryBuilder->expr()->eq(
'tablenames',
$queryBuilder->createNamedParameter('be_users')
),
$queryBuilder->expr()->eq(
'fieldname',
$queryBuilder->createNamedParameter('avatar')
),
$queryBuilder->expr()->eq(
'uid_foreign',
$queryBuilder->createNamedParameter($beUserId, Connection::PARAM_INT)
)
)
->executeQuery()
->fetchOne();
return (int)$file;
}
/**
* Set avatar fileUid for backend user
*
* @param numeric-string|''|'delete'|null $fileUid either null, a file UID, an empty string, or `delete`
*/
protected function setAvatarFileUid(int $beUserId, ?string $fileUid, array &$storeRec): void
{
// Update is only needed when new fileUid is set
if ((int)$fileUid === $this->getAvatarFileUid($beUserId)) {
return;
}
// If user is not allowed to modify avatar $fileUid is empty - so don't overwrite existing avatar
if (empty($fileUid)) {
return;
}
$queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('sys_file_reference');
$queryBuilder->getRestrictions()->removeAll();
$queryBuilder
->delete('sys_file_reference')
->where(
$queryBuilder->expr()->eq(
'tablenames',
$queryBuilder->createNamedParameter('be_users')
),
$queryBuilder->expr()->eq(
'fieldname',
$queryBuilder->createNamedParameter('avatar')
),
$queryBuilder->expr()->eq(
'uid_foreign',
$queryBuilder->createNamedParameter($beUserId, Connection::PARAM_INT)
)
)
->executeStatement();
// If Avatar is marked for delete => set it to empty string so it will be updated properly
if ($fileUid === 'delete') {
$fileUid = '';
}
// Create new reference
if ((int)$fileUid > 0) {
// Get file object
try {
$file = $this->resourceFactory->getFileObject((int)$fileUid);
} catch (FileDoesNotExistException $e) {
$file = false;
}
// Check if user is allowed to use the image (only when not in simulation mode)
if ($file && !$file->getStorage()->checkFileActionPermission('read', $file)) {
$file = false;
}
// Check if extension is allowed
if ($file && $file->isImage()) {
// Create new file reference
$storeRec['sys_file_reference']['NEW1234'] = [
'uid_local' => (int)$fileUid,
'uid_foreign' => (int)$beUserId,
'tablenames' => 'be_users',
'fieldname' => 'avatar',
'pid' => 0,
];
$storeRec['be_users'][(int)$beUserId]['avatar'] = 'NEW1234';
}
}
}
/**
* Register the reset configuration button to the button bar.
*/
protected function registerResetButtonToButtonBar(ModuleTemplate $view): void
{
$languageService = $this->getLanguageService();
$resetButton = $this->componentFactory->createGenericButton()
->setTag('button')
->setLabel($languageService->translate('reset_configuration_button', 'backend.user_profile'))
->setTitle($languageService->translate('reset_configuration', 'backend.user_profile'))
->setIcon($this->iconFactory->getIcon('actions-undo', IconSize::SMALL))
->setShowLabelText(true)
->setClasses('t3js-modal-trigger')
->setAttributes([
'type' => 'button',
'data-severity' => 'warning',
'data-title' => $languageService->translate('reset_configuration', 'backend.user_profile'),
'data-content' => $languageService->translate('set_to_standard_question', 'backend.user_profile'),
'data-event' => 'confirm',
'data-event-name' => 'setup:confirmation:response',
'data-event-payload' => 'resetConfiguration',
]);
$view->addButtonToButtonBar($resetButton, ButtonBar::BUTTON_POSITION_RIGHT);
}
/**
* Add FlashMessages for various actions
*/
protected function addFlashMessages(ModuleTemplate $view): void
{
$languageService = $this->getLanguageService();
if ($this->setupIsUpdated && !$this->settingsAreResetToDefault) {
$view->addFlashMessage($languageService->translate('setup_was_updated', 'backend.user_profile'), $languageService->translate('user_settings', 'backend.user_profile'));
}
if ($this->settingsAreResetToDefault) {
$view->addFlashMessage($languageService->translate('settings_are_reset', 'backend.user_profile'), $languageService->translate('reset_configuration', 'backend.user_profile'));
}
if ($this->passwordIsSubmitted) {
switch ($this->passwordIsUpdated) {
case self::PASSWORD_NOT_THE_SAME:
$view->addFlashMessage($languageService->translate('new_password_failed', 'backend.user_profile'), $languageService->translate('new_password', 'backend.user_profile'), ContextualFeedbackSeverity::ERROR);
break;
case self::PASSWORD_UPDATED:
$view->addFlashMessage($languageService->translate('new_password_ok', 'backend.user_profile'), $languageService->translate('new_password', 'backend.user_profile'));
break;
case self::PASSWORD_POLICY_FAILED:
$view->addFlashMessage($languageService->translate('password_policy_failed', 'backend.user_profile'), $languageService->translate('new_password', 'backend.user_profile'), ContextualFeedbackSeverity::ERROR);
break;
}
}
}
/**
* @param array<string, scalar> $data
* @return array<string, scalar>
*/
protected function extractPartitionData(array $data, string $partition): array
{
$partitionData = [];
$prefix = $partition . '__';
$length = strlen($prefix);
foreach ($data as $key => $value) {
if (!str_starts_with($key, $prefix)) {
continue;
}
$normalizedKey = substr($key, $length);
$partitionData[$normalizedKey] = $value;
}
return $partitionData;
}
protected function getBackendUser(): BackendUserAuthentication
{
return $GLOBALS['BE_USER'];
}
protected function getLanguageService(): LanguageService
{
return $GLOBALS['LANG'];
}
}
@@ -0,0 +1,290 @@
<?php
declare(strict_types=1);
/*
* 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 TYPO3\CMS\Backend\Controller;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Backend\Attribute\AsController;
use TYPO3\CMS\Backend\Clipboard\Clipboard;
use TYPO3\CMS\Backend\Utility\BackendUtility;
use TYPO3\CMS\Core\DataHandling\DataHandler;
use TYPO3\CMS\Core\Http\HtmlResponse;
use TYPO3\CMS\Core\Http\JsonResponse;
use TYPO3\CMS\Core\Http\RedirectResponse;
use TYPO3\CMS\Core\Messaging\FlashMessageService;
use TYPO3\CMS\Core\Type\ContextualFeedbackSeverity;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Script Class, creating object of \TYPO3\CMS\Core\DataHandling\DataHandler and
* sending the posted data to the object.
*
* Used by many smaller forms/links in TYPO3, including the QuickEdit module.
* Is not used by FormEngine though (main form rendering script) - that uses the same class (DataHandler) but makes its own initialization (to save the redirect request).
* For all other cases than FormEngine it is recommended to use this script for submitting your editing forms - but the best solution in any case would probably be to link your application to FormEngine, that will give you easy form-rendering as well.
*/
#[AsController]
class SimpleDataHandlerController
{
/**
* Array. Accepts options to be set in TCE object. Currently it supports "reverseOrder" (bool).
*
* @var array
*/
protected $flags;
/**
* Data array on the form [tablename][uid][fieldname] = value
*
* @var array
*/
protected $data;
/**
* Command array on the form [tablename][uid][command] = value.
* This array may get additional data set internally based on clipboard commands send in CB var!
*
* @var array
*/
protected $cmd;
/**
* Array passed to ->setMirror.
*
* @var array
*/
protected $mirror;
/**
* Cache command sent to ->clear_cacheCmd
*
* @var string
*/
protected $cacheCmd;
/**
* Redirect URL. Script will redirect to this location after performing operations (unless errors has occurred)
*
* @var string
*/
protected $redirect;
/**
* Clipboard command array. May trigger changes in "cmd"
*
* @var array
*/
protected $CB;
/**
* TYPO3 Core Engine
*
* @var \TYPO3\CMS\Core\DataHandling\DataHandler
*/
protected $tce;
public function __construct(
protected readonly FlashMessageService $flashMessageService,
) {}
/**
* Injects the request object for the current request or subrequest
* As this controller goes only through the processRequest() method, it just redirects to the given URL afterwards.
*
* @param ServerRequestInterface $request the current request
* @return ResponseInterface the response with the content
*/
public function mainAction(ServerRequestInterface $request): ResponseInterface
{
$this->init($request);
$this->initializeClipboard($request);
$this->processRequest();
// Write errors to flash message queue
$this->tce->printLogErrorMessages();
if ($this->redirect) {
return new RedirectResponse(GeneralUtility::locationHeaderUrl($this->redirect, $request), 303);
}
return new HtmlResponse('');
}
/**
* Processes all AJAX calls and returns a JSON formatted string
*/
public function processAjaxRequest(ServerRequestInterface $request): ResponseInterface
{
$this->init($request);
// do the regular / main logic
$this->initializeClipboard($request);
$this->processRequest();
$content = [
'redirect' => $this->redirect,
'messages' => [],
'hasErrors' => false,
];
// Prints errors (= write them to the message queue)
$this->tce->printLogErrorMessages();
$messages = $this->flashMessageService->getMessageQueueByIdentifier()->getAllMessagesAndFlush();
if (!empty($messages)) {
foreach ($messages as $message) {
$content['messages'][] = [
'title' => $message->getTitle(),
'message' => $message->getMessage(),
'severity' => $message->getSeverity(),
];
if ($message->getSeverity() === ContextualFeedbackSeverity::ERROR) {
$content['hasErrors'] = true;
}
}
}
return new JsonResponse($content);
}
/**
* Initialization of the class
*/
protected function init(ServerRequestInterface $request): void
{
$parsedBody = $request->getParsedBody();
$queryParams = $request->getQueryParams();
// GPvars:
$this->flags = (array)($parsedBody['flags'] ?? $queryParams['flags'] ?? []);
$this->data = (array)($parsedBody['data'] ?? $queryParams['data'] ?? []);
$this->cmd = (array)($parsedBody['cmd'] ?? $queryParams['cmd'] ?? []);
$this->mirror = (array)($parsedBody['mirror'] ?? $queryParams['mirror'] ?? []);
$this->cacheCmd = (string)($parsedBody['cacheCmd'] ?? $queryParams['cacheCmd'] ?? '');
$this->CB = (array)($parsedBody['CB'] ?? $queryParams['CB'] ?? []);
$this->redirect = GeneralUtility::sanitizeLocalUrl((string)($parsedBody['redirect'] ?? $queryParams['redirect'] ?? ''), $request);
// Creating DataHandler object
$this->tce = GeneralUtility::makeInstance(DataHandler::class);
// Reverse order.
if ($this->flags['reverseOrder'] ?? false) {
$this->tce->reverseOrder = true;
}
}
/**
* Clipboard pasting and deleting.
*/
protected function initializeClipboard(ServerRequestInterface $request): void
{
if ($this->CB !== []) {
$clipObj = GeneralUtility::makeInstance(Clipboard::class);
$clipObj->initializeClipboard($request);
if ($this->CB['paste'] ?? false) {
$clipObj->setCurrentPad((string)($this->CB['pad'] ?? ''));
$this->setPasteCmd($clipObj);
}
if ($this->CB['delete'] ?? false) {
$clipObj->setCurrentPad((string)($this->CB['pad'] ?? ''));
$this->setDeleteCmd($clipObj);
}
}
}
/**
* Executing the posted actions ...
*/
protected function processRequest(): void
{
// LOAD DataHandler with data and cmd arrays:
$this->tce->start($this->data, $this->cmd);
if ($this->mirror !== []) {
$this->tce->setMirror($this->mirror);
}
// Execute actions:
$this->tce->process_datamap();
$this->tce->process_cmdmap();
// Clearing cache:
if (!empty($this->cacheCmd)) {
$this->tce->clear_cacheCmd($this->cacheCmd);
}
// Update page tree?
if (isset($this->data['pages']) || isset($this->cmd['pages'])) {
BackendUtility::setUpdateSignal('updatePageTree');
}
}
/**
* Applies the proper paste configuration to $this->cmd
*
* The reference ($this->CB['paste']) has following format: [tablename]:[paste-uid].
* Tablename is the name of the table from which elements *on the current clipboard* is pasted with the 'pid' paste-uid.
* No tablename means that all items on the clipboard (non-files) are pasted. This requires paste-uid to be positive though.
* so 'tt_content:-3' means 'paste tt_content elements on the clipboard to AFTER tt_content:3 record
* 'tt_content:30' means 'paste tt_content elements on the clipboard into page with id 30
* ':30' means 'paste ALL database elements on the clipboard into page with id 30
* ':-30' not valid.
*/
protected function setPasteCmd(Clipboard $clipboard): void
{
[$pasteTable, $pasteUid] = explode('|', (string)$this->CB['paste']);
$pasteUid = (int)$pasteUid;
// pUid must be set and if pTable is not set (that means paste ALL elements)
// the uid MUST be positive/zero (pointing to page id)
if (!$pasteTable && $pasteUid < 0) {
return;
}
$elements = $clipboard->elFromTable($pasteTable);
// So the order is preserved.
$elements = array_reverse($elements);
$mode = $clipboard->currentMode() === 'copy' ? 'copy' : 'move';
// Traverse elements and make CMD array
foreach ($elements as $key => $value) {
[$table, $uid] = explode('|', $key);
if (!is_array($this->cmd[$table] ?? null)) {
$this->cmd[$table] = [];
}
if (is_array($this->CB['update'] ?? false)) {
$this->cmd[$table][$uid][$mode] = [
'action' => 'paste',
'target' => $pasteUid,
'update' => $this->CB['update'],
];
} else {
$this->cmd[$table][$uid][$mode] = $pasteUid;
}
if ($mode === 'move') {
$clipboard->removeElement($key);
}
}
$clipboard->endClipboard();
}
/**
* Applies the proper delete configuration to $this->cmd
*/
protected function setDeleteCmd(Clipboard $clipboard): void
{
foreach ($clipboard->elFromTable() as $key => $value) {
[$table, $uid] = explode('|', $key);
if (!is_array($this->cmd[$table])) {
$this->cmd[$table] = [];
}
$this->cmd[$table][$uid]['delete'] = 1;
$clipboard->removeElement($key);
}
$clipboard->endClipboard();
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,403 @@
<?php
declare(strict_types=1);
/*
* 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 TYPO3\CMS\Backend\Controller;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Backend\Attribute\AsController;
use TYPO3\CMS\Backend\Configuration\SiteTcaConfiguration;
use TYPO3\CMS\Backend\Form\FormDataCompiler;
use TYPO3\CMS\Backend\Form\FormDataGroup\SiteConfigurationDataGroup;
use TYPO3\CMS\Backend\Form\InlineStackProcessor;
use TYPO3\CMS\Backend\Form\NodeFactory;
use TYPO3\CMS\Core\Crypto\HashService;
use TYPO3\CMS\Core\Http\JsonResponse;
use TYPO3\CMS\Core\Page\JavaScriptItems;
use TYPO3\CMS\Core\Schema\TcaSchemaBuilder;
use TYPO3\CMS\Core\Site\Entity\SiteLanguage;
use TYPO3\CMS\Core\Site\SiteFinder;
use TYPO3\CMS\Core\Site\SiteLanguagePresets;
use TYPO3\CMS\Core\Utility\ArrayUtility;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Core\Utility\MathUtility;
/**
* Site configuration FormEngine controller class. Receives inline "edit" and "new"
* commands to expand / create site configuration inline records.
*
* @internal This class is a specific Backend controller implementation and is not considered part of the Public TYPO3 API.
*/
#[AsController]
readonly class SiteInlineAjaxController extends AbstractFormEngineAjaxController
{
public function __construct(
private FormDataCompiler $formDataCompiler,
private SiteLanguagePresets $siteLanguagePresets,
private HashService $hashService,
private NodeFactory $nodeFactory,
private InlineStackProcessor $inlineStackProcessor,
private TcaSchemaBuilder $tcaSchemaBuilder,
private SiteTcaConfiguration $siteTcaConfiguration,
private SiteFinder $siteFinder,
) {}
/**
* Inline "create" new child of site configuration child records
*
* @throws \RuntimeException
*/
public function newInlineChildAction(ServerRequestInterface $request): ResponseInterface
{
$ajaxArguments = $request->getParsedBody()['ajax'] ?? $request->getQueryParams()['ajax'];
$parentConfig = $this->extractSignedParentConfigFromRequest((string)$ajaxArguments['context']);
$domObjectId = $ajaxArguments[0];
$inlineFirstPid = $this->getInlineFirstPidFromDomObjectId($domObjectId);
$childChildUid = null;
if (isset($ajaxArguments[1]) && MathUtility::canBeInterpretedAsInteger($ajaxArguments[1])) {
$childChildUid = (int)$ajaxArguments[1];
}
$siteTca = $this->siteTcaConfiguration->getTca();
$fullTca = array_merge($GLOBALS['TCA'], $siteTca);
$tcaSchemata = $this->tcaSchemaBuilder->buildFromStructure($fullTca);
// Parse the DOM identifier, add the levels to the structure stack
$inlineStructure = $this->inlineStackProcessor->getStructureFromString($domObjectId, $tcaSchemata);
$inlineStructure = $this->inlineStackProcessor->addAjaxConfigurationToStructure($inlineStructure, $parentConfig);
$inlineTopMostParent = $this->inlineStackProcessor->getStructureLevelFromStructure($inlineStructure, 0);
// Parent, this table embeds the child table
$inlineParent = $this->inlineStackProcessor->getStructureLevelFromStructure($inlineStructure, -1);
// Child, a record from this table should be rendered
$child = $this->inlineStackProcessor->getUnstableStructureFromStructure($inlineStructure);
if (MathUtility::canBeInterpretedAsInteger($child['uid'] ?? false)) {
// If uid comes in, it is the id of the record neighbor record "create after"
$childVanillaUid = -1 * abs((int)$child['uid']);
} else {
// Else inline first Pid is the storage pid of new inline records
$childVanillaUid = (int)$inlineFirstPid;
}
$childTableName = $parentConfig['foreign_table'];
$defaultDatabaseRow = [];
if ($childTableName === 'site_language') {
if ($childChildUid !== null) {
$language = $this->getLanguageById($childChildUid);
if ($language !== null) {
$defaultDatabaseRow['languageId'] = $language->getLanguageId();
$defaultDatabaseRow['locale'] = $language->getLocale()->posixFormatted();
if ($language->getTitle() !== '') {
$defaultDatabaseRow['title'] = $language->getTitle();
}
if ($language->getBase()->getPath() !== '/') {
$defaultDatabaseRow['base'] = '/' . strtolower($language->getLocale()->getName()) . '/';
}
if ($language->getHreflang(true) !== '') {
$defaultDatabaseRow['hreflang'] = $language->getHreflang();
}
if ($language->getNavigationTitle() !== '') {
$defaultDatabaseRow['navigationTitle'] = $language->getNavigationTitle();
}
if (str_starts_with($language->getFlagIdentifier(), 'flags-')) {
$flagIdentifier = str_replace('flags-', '', $language->getFlagIdentifier());
$defaultDatabaseRow['flag'] = ($flagIdentifier === 'multiple') ? 'global' : $flagIdentifier;
}
} elseif ($childChildUid !== 0) {
// In case no language could be found for $childChildUid and
// its value is not "0", which is a special case as the default
// language is added automatically, throw a custom exception.
throw new \RuntimeException('Referenced language not found', 1521783937);
}
} else {
// Set new childs' UID to PHP_INT_MAX, as this is the placeholder UID for
// new records, created with the "Create new" button. This is necessary
// as we use the "inline selector" mode which usually does not allow
// to create new records besides the ones, defined in the selector.
// The correct UID will then be calculated by the controller.
$childChildUid = PHP_INT_MAX;
if (!empty($ajaxArguments[2])) {
$defaultDatabaseRow = $this->siteLanguagePresets->getPresetDetailsForLanguage($ajaxArguments[2]) ?? [];
}
}
}
$formDataCompilerInput = [
'request' => $request,
'command' => 'new',
'tableName' => $childTableName,
'vanillaUid' => $childVanillaUid,
'databaseRow' => $defaultDatabaseRow,
'isInlineChild' => true,
'inlineStructure' => $inlineStructure,
'inlineFirstPid' => $inlineFirstPid,
'inlineParentUid' => $inlineParent['uid'],
'inlineParentTableName' => $inlineParent['table'],
'inlineParentFieldName' => $inlineParent['field'],
'inlineParentConfig' => $parentConfig,
'inlineTopMostParentUid' => $inlineTopMostParent['uid'],
'inlineTopMostParentTableName' => $inlineTopMostParent['table'],
'inlineTopMostParentFieldName' => $inlineTopMostParent['field'],
'tcaSchemata' => $tcaSchemata,
'fullTca' => $fullTca,
];
if ($childChildUid) {
$formDataCompilerInput['inlineChildChildUid'] = $childChildUid;
}
$childData = $this->formDataCompiler->compile($formDataCompilerInput, GeneralUtility::makeInstance(SiteConfigurationDataGroup::class));
if (($parentConfig['foreign_selector'] ?? false) && ($parentConfig['appearance']['useCombination'] ?? false)) {
throw new \RuntimeException('useCombination not implemented in sites module', 1522493094);
}
$childData['inlineParentUid'] = (int)$inlineParent['uid'];
$childData['renderType'] = 'inlineRecordContainer';
$childResult = $this->nodeFactory->create($childData)->render();
$jsonArray = [
'data' => '',
'stylesheetFiles' => [],
'scriptItems' => new JavaScriptItems(),
'compilerInput' => [
'uid' => $childData['databaseRow']['uid'],
'childChildUid' => $childChildUid,
],
];
$jsonArray = $this->mergeChildResultIntoJsonResult($jsonArray, $childResult);
return new JsonResponse($jsonArray);
}
/**
* Show the details of site configuration child records.
*
* @throws \RuntimeException
*/
public function openInlineChildAction(ServerRequestInterface $request): ResponseInterface
{
$ajaxArguments = $request->getParsedBody()['ajax'] ?? $request->getQueryParams()['ajax'];
$domObjectId = $ajaxArguments[0];
$inlineFirstPid = $this->getInlineFirstPidFromDomObjectId($domObjectId);
$parentConfig = $this->extractSignedParentConfigFromRequest((string)$ajaxArguments['context']);
$siteTca = $this->siteTcaConfiguration->getTca();
$fullTca = array_merge($GLOBALS['TCA'], $siteTca);
// Parse the DOM identifier, add the levels to the structure stack
$inlineStructure = $this->inlineStackProcessor->getStructureFromString($domObjectId, $this->tcaSchemaBuilder->buildFromStructure($fullTca));
$inlineStructure = $this->inlineStackProcessor->addAjaxConfigurationToStructure($inlineStructure, $parentConfig);
// Parent, this table embeds the child table
$inlineParent = $this->inlineStackProcessor->getStructureLevelFromStructure($inlineStructure, -1);
$parentFieldName = $inlineParent['field'];
// Set flag in config so that only the fields are rendered
// @todo: Solve differently / rename / whatever
$parentConfig['renderFieldsOnly'] = true;
$parentData = [
'processedTca' => [
'columns' => [
$parentFieldName => [
'config' => $parentConfig,
],
],
],
'uid' => $inlineParent['uid'],
'tableName' => $inlineParent['table'],
'inlineFirstPid' => $inlineFirstPid,
// Hand over given original return url to compile stack. Needed if inline children compile links to
// another view (eg. edit metadata in a nested inline situation like news with inline content element image),
// so the back link is still the link from the original request. See issue #82525. This is additionally
// given down in TcaInline data provider to compiled children data.
'returnUrl' => $parentConfig['originalReturnUrl'],
];
// Child, a record from this table should be rendered
$child = $this->inlineStackProcessor->getUnstableStructureFromStructure($inlineStructure);
$childData = $this->compileChild($request, $parentData, $parentFieldName, (int)$child['uid'], $inlineStructure, $siteTca);
$childData['inlineParentUid'] = (int)$inlineParent['uid'];
$childData['renderType'] = 'inlineRecordContainer';
$childResult = $this->nodeFactory->create($childData)->render();
$jsonArray = [
'data' => '',
'stylesheetFiles' => [],
'scriptItems' => new JavaScriptItems(),
];
$jsonArray = $this->mergeChildResultIntoJsonResult($jsonArray, $childResult);
return new JsonResponse($jsonArray);
}
/**
* Compile a full child record
*
* @param array $parentData Result array of parent
* @param string $parentFieldName Name of parent field
* @param int $childUid Uid of child to compile
* @param array $inlineStructure Current inline structure
* @return array Full result array
* @throws \RuntimeException
*
* @todo: This clones methods compileChild from TcaInline Provider. Find a better abstraction
* @todo: to also encapsulate the more complex scenarios with combination child and friends.
*/
protected function compileChild(ServerRequestInterface $request, array $parentData, string $parentFieldName, int $childUid, array $inlineStructure, array $siteTca): array
{
$parentConfig = $parentData['processedTca']['columns'][$parentFieldName]['config'];
$inlineTopMostParent = $this->inlineStackProcessor->getStructureLevelFromStructure($inlineStructure, 0);
$childTableName = $inlineStructure['unstable']['table'] ?? null;
if (!$childTableName) {
throw new \RuntimeException('No unstable inline structure found', 1733754246);
}
$fullTca = array_merge($GLOBALS['TCA'], $siteTca);
$formDataCompilerInput = [
'request' => $request,
'command' => 'edit',
'tableName' => $childTableName,
'vanillaUid' => (int)$childUid,
'returnUrl' => $parentData['returnUrl'],
'isInlineChild' => true,
'inlineStructure' => $inlineStructure,
'inlineFirstPid' => $parentData['inlineFirstPid'],
'inlineParentConfig' => $parentConfig,
'isInlineAjaxOpeningContext' => true,
'tcaSchemata' => $this->tcaSchemaBuilder->buildFromStructure($fullTca),
'fullTca' => $fullTca,
// values of the current parent element
// it is always a string either an id or new...
'inlineParentUid' => $parentData['uid'],
'inlineParentTableName' => $parentData['tableName'],
'inlineParentFieldName' => $parentFieldName,
// values of the top most parent element set on first level and not overridden on following levels
'inlineTopMostParentUid' => $inlineTopMostParent['uid'],
'inlineTopMostParentTableName' => $inlineTopMostParent['table'],
'inlineTopMostParentFieldName' => $inlineTopMostParent['field'],
];
if (($parentConfig['foreign_selector'] ?? false) && ($parentConfig['appearance']['useCombination'] ?? false)) {
throw new \RuntimeException('useCombination not implemented in sites module', 1522493095);
}
return $this->formDataCompiler->compile($formDataCompilerInput, GeneralUtility::makeInstance(SiteConfigurationDataGroup::class));
}
/**
* Merge stuff from child array into json array.
* This method is needed since ajax handling methods currently need to put scriptCalls before and after child code.
*
* @param array $jsonResult Given json result
* @param array $childResult Given child result
* @return array Merged json array
*/
protected function mergeChildResultIntoJsonResult(array $jsonResult, array $childResult): array
{
/** @var JavaScriptItems $scriptItems */
$scriptItems = $jsonResult['scriptItems'];
$jsonResult['data'] .= $childResult['html'];
$jsonResult['stylesheetFiles'] = [];
foreach ($childResult['stylesheetFiles'] as $stylesheetFile) {
$jsonResult['stylesheetFiles'][] = $this->getRelativePathToStylesheetFile($stylesheetFile);
}
if (!empty($childResult['inlineData'])) {
$jsonResult['inlineData'] = $childResult['inlineData'];
}
if (!empty($childResult['additionalInlineLanguageLabelFiles'])) {
$labels = [];
foreach ($childResult['additionalInlineLanguageLabelFiles'] as $additionalInlineLanguageLabelFile) {
ArrayUtility::mergeRecursiveWithOverrule(
$labels,
$this->getLabelsFromLocalizationFile($additionalInlineLanguageLabelFile)
);
}
$scriptItems->addGlobalAssignment(['TYPO3' => ['lang' => $labels]]);
}
$this->addJavaScriptModulesToJavaScriptItems($childResult['javaScriptModules'] ?? [], $scriptItems);
return $jsonResult;
}
/**
* Inline ajax helper method.
*
* Validates the config that is transferred over the wire to provide the
* correct TCA config for the parent table
*
* @param string $contextString
* @throws \RuntimeException
*/
protected function extractSignedParentConfigFromRequest(string $contextString): array
{
if ($contextString === '') {
throw new \RuntimeException('Empty context string given', 1522771624);
}
$context = json_decode($contextString, true);
if (empty($context['config'])) {
throw new \RuntimeException('Empty context config section given', 1522771632);
}
$config = json_decode($context['config'], true);
// encode JSON again to ensure same `json_encode()` settings as used when generating original hash
// (side-note: JSON encoded literals differ for target scenarios, e.g. HTML attr, JS string, ...)
$encodedConfig = (string)json_encode($config);
if (!hash_equals($this->hashService->hmac($encodedConfig, 'InlineContext'), (string)$context['hmac'])) {
throw new \RuntimeException('Hash does not validate', 1522771640);
}
return $config;
}
/**
* Get inlineFirstPid from a given objectId string
*
* @param string $domObjectId The id attribute of an element
* @return int|null Pid or null
*/
protected function getInlineFirstPidFromDomObjectId(string $domObjectId): ?int
{
// Substitute FlexForm addition and make parsing a bit easier
$domObjectId = str_replace('---', ':', $domObjectId);
// The starting pattern of an object identifier (e.g. "data-<firstPidValue>-<anything>)
$pattern = '/^data-(.+?)-(.+)$/';
if (preg_match($pattern, $domObjectId, $match)) {
return (int)$match[1];
}
return null;
}
/**
* Find a site language by id. This will return the first occurrence of a
* language, even if the same language is used in other site configurations.
*/
protected function getLanguageById(int $languageId): ?SiteLanguage
{
foreach ($this->siteFinder->getAllSites() as $site) {
foreach ($site->getAllLanguages() as $language) {
if ($languageId === $language->getLanguageId()) {
return $language;
}
}
}
return null;
}
}
@@ -0,0 +1,380 @@
<?php
declare(strict_types=1);
/*
* 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 TYPO3\CMS\Backend\Controller;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Symfony\Component\Yaml\Yaml;
use TYPO3\CMS\Backend\Attribute\AsController;
use TYPO3\CMS\Backend\Dto\Settings\EditableSetting;
use TYPO3\CMS\Backend\Routing\UriBuilder;
use TYPO3\CMS\Backend\Template\Components\ButtonBar;
use TYPO3\CMS\Backend\Template\Components\ComponentFactory;
use TYPO3\CMS\Backend\Template\Enum\ModuleLayout;
use TYPO3\CMS\Backend\Template\ModuleTemplate;
use TYPO3\CMS\Backend\Template\ModuleTemplateFactory;
use TYPO3\CMS\Backend\Utility\BackendUtility;
use TYPO3\CMS\Backend\View\SetupSettingsViewMode;
use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
use TYPO3\CMS\Core\FormProtection\FormProtectionFactory;
use TYPO3\CMS\Core\Http\JsonResponse;
use TYPO3\CMS\Core\Http\RedirectResponse;
use TYPO3\CMS\Core\Http\ResponseFactory;
use TYPO3\CMS\Core\Imaging\IconFactory;
use TYPO3\CMS\Core\Imaging\IconSize;
use TYPO3\CMS\Core\Localization\LanguageService;
use TYPO3\CMS\Core\Messaging\FlashMessage;
use TYPO3\CMS\Core\Messaging\FlashMessageService;
use TYPO3\CMS\Core\Page\PageRenderer;
use TYPO3\CMS\Core\Settings\Category;
use TYPO3\CMS\Core\Settings\SettingDefinition;
use TYPO3\CMS\Core\Settings\SettingsTypeRegistry;
use TYPO3\CMS\Core\Site\Entity\Site;
use TYPO3\CMS\Core\Site\Set\CategoryRegistry;
use TYPO3\CMS\Core\Site\SiteFinder;
use TYPO3\CMS\Core\Site\SiteSettingsService;
use TYPO3\CMS\Core\SysLog\Action\Setting as SettingAction;
use TYPO3\CMS\Core\SysLog\Error as SystemLogErrorClassification;
use TYPO3\CMS\Core\SysLog\Type;
use TYPO3\CMS\Core\Type\ContextualFeedbackSeverity;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* View, edit, save site settings. Part of Setup module.
*
* @internal This class is a specific Backend controller implementation and is not considered part of the Public TYPO3 API.
*/
#[AsController]
readonly class SiteSettingsController
{
public function __construct(
protected ComponentFactory $componentFactory,
protected ModuleTemplateFactory $moduleTemplateFactory,
protected SiteFinder $siteFinder,
protected SiteSettingsService $siteSettingsService,
protected SettingsTypeRegistry $settingsTypeRegistry,
protected CategoryRegistry $categoryRegistry,
protected UriBuilder $uriBuilder,
protected PageRenderer $pageRenderer,
protected FlashMessageService $flashMessageService,
protected IconFactory $iconFactory,
protected ResponseFactory $responseFactory,
protected FormProtectionFactory $formProtectionFactory,
) {}
public function editAction(ServerRequestInterface $request): ResponseInterface
{
$moduleData = $request->getAttribute('moduleData');
$mode = SetupSettingsViewMode::tryFrom($moduleData->get('settingsMode') ?? '') ?? SetupSettingsViewMode::BASIC;
$moduleData->set('settingsMode', $mode->value);
$identifier = $request->getQueryParams()['site'] ?? null;
if ($identifier === null) {
throw new \RuntimeException('Site identifier to edit must be set', 1713394528);
}
$returnUrl = GeneralUtility::sanitizeLocalUrl(
(string)($request->getQueryParams()['returnUrl'] ?? ''),
$request
) ?: null;
$overviewUrl = (string)$this->uriBuilder->buildUriFromRoute('site_configuration');
$site = $this->siteFinder->getSiteByIdentifier($identifier);
$view = $this->moduleTemplateFactory->create($request);
$settings = $this->siteSettingsService->getUncachedSettings($site);
$setSettings = $this->siteSettingsService->getSetSettings($site);
$categoryEnhancer = function (Category $category) use (&$categoryEnhancer, $settings, $setSettings): Category {
return new Category(...[
...get_object_vars($category),
'label' => $this->getLanguageService()->sL($category->label),
'description' => $category->description !== null ? $this->getLanguageService()->sL($category->description) : $category->description,
'categories' => array_map($categoryEnhancer, $category->categories),
'settings' => array_map(
fn(SettingDefinition $definition): EditableSetting => new EditableSetting(
definition: $this->resolveSettingLabels($definition),
value: $settings->get($definition->key),
systemDefault: $setSettings->get($definition->key),
typeImplementation: $this->settingsTypeRegistry->get($definition->type)->getJavaScriptModule(),
),
$category->settings
),
]);
};
$categories = array_map(
$categoryEnhancer,
$this->categoryRegistry->getCategories(...$site->getSets())
);
$hasSettings = count($categories) > 0;
$this->addDocHeaderBreadcrumb($view, $site);
$this->addDocHeaderCloseAndSaveButtons($view, $returnUrl ?? $overviewUrl, $hasSettings);
$this->addDocHeaderViewModeButton($view, $site, $mode);
$this->addDocHeaderSiteConfigurationButton($view, $site);
if ($hasSettings) {
$this->addDocHeaderExportButton($view, $mode);
}
// Set shortcut context - reload button is added automatically
$view->getDocHeaderComponent()->setShortcutContext(
'site_configuration.editSettings',
sprintf($this->getLanguageService()->sL('LLL:EXT:backend/Resources/Private/Language/locallang_sitesettings.xlf:labels.edit'), $site->getIdentifier()),
['site' => $site->getIdentifier()]
);
$view->setLayout(ModuleLayout::NORMAL);
$view->assign('site', $site);
$view->assign('siteTitle', $this->getSiteTitle($site));
$view->assign('rootPageId', $site->getRootPageId());
$view->assign('actionUrl', (string)$this->uriBuilder->buildUriFromRoute('site_configuration.saveSettings', array_filter([
'site' => $site->getIdentifier(),
'returnUrl' => $returnUrl,
])));
$view->assign('returnUrl', $returnUrl);
$view->assign('dumpUrl', (string)$this->uriBuilder->buildUriFromRoute('site_configuration.dumpSettings', ['site' => $site->getIdentifier()]));
$view->assign('categories', $categories);
$view->assign('mode', $mode);
$formProtection = $this->formProtectionFactory->createFromRequest($request);
$view->assign('formToken', $formProtection->generateToken('site_configuration', 'saveSettings'));
return $view->renderResponse('SiteSettings/Edit');
}
private function resolveSettingLabels(SettingDefinition $definition): SettingDefinition
{
$languageService = $this->getLanguageService();
return new SettingDefinition(...[
...get_object_vars($definition),
'label' => $languageService->sL($definition->label),
'description' => $definition->description !== null ? $languageService->sL($definition->description) : null,
'enum' => array_map(
static fn(string|int|float|bool $label): string => $languageService->sL((string)$label),
$definition->enum
),
]);
}
public function saveAction(ServerRequestInterface $request): ResponseInterface
{
$identifier = $request->getQueryParams()['site'] ?? null;
if ($identifier === null) {
throw new \RuntimeException('Site identifier to edit must be set', 1713394529);
}
$site = $this->siteFinder->getSiteByIdentifier($identifier);
$parsedBody = $request->getParsedBody();
$formProtection = $this->formProtectionFactory->createFromRequest($request);
if (!$formProtection->validateToken((string)($parsedBody['formToken'] ?? ''), 'site_configuration', 'saveSettings')) {
return $this->responseFactory
->createResponse(400, 'Invalid request token given')
->withHeader('Location', (string)$this->uriBuilder->buildUriFromRoute('site_configuration.editSettings', [
'site' => $site->getIdentifier(),
]));
}
$returnUrl = GeneralUtility::sanitizeLocalUrl(
(string)($parsedBody['returnUrl'] ?? ''),
$request
) ?: null;
$overviewUrl = $this->uriBuilder->buildUriFromRoute('site_configuration');
$CMD = $parsedBody['CMD'] ?? '';
$isSave = $CMD === 'save' || $CMD === 'saveclose';
$isSaveClose = $parsedBody['CMD'] === 'saveclose';
if (!$isSave) {
return new RedirectResponse($returnUrl ?? $overviewUrl);
}
$newSettings = $this->siteSettingsService->createSettingsFromFormData($site, $parsedBody['settings'] ?? []);
$settingsDiff = $this->siteSettingsService->computeSettingsDiff($site, $newSettings);
$this->siteSettingsService->writeSettings($site, $settingsDiff->asArray());
if ($settingsDiff->changes !== [] || $settingsDiff->deletions !== []) {
$this->getBackendUser()->writelog(
Type::SITE,
SettingAction::CHANGE,
SystemLogErrorClassification::MESSAGE,
null,
'Site settings changed for \'%s\': %s',
[$site->getIdentifier(), json_encode($settingsDiff)],
'site'
);
$languageService = $this->getLanguageService();
$message = $languageService->sL('LLL:EXT:backend/Resources/Private/Language/locallang_sitesettings.xlf:save.message.updated');
$flashMessage = new FlashMessage($message, '', ContextualFeedbackSeverity::OK, true);
$defaultFlashMessageQueue = $this->flashMessageService->getMessageQueueByIdentifier();
$defaultFlashMessageQueue->enqueue($flashMessage);
}
if ($isSaveClose) {
return new RedirectResponse($returnUrl ?? $overviewUrl);
}
$editRoute = $this->uriBuilder->buildUriFromRoute('site_configuration.editSettings', array_filter([
'site' => $site->getIdentifier(),
'returnUrl' => $returnUrl,
], static fn(?string $v): bool => $v !== null));
return new RedirectResponse($editRoute);
}
public function dumpAction(ServerRequestInterface $request): ResponseInterface
{
$identifier = $request->getQueryParams()['site'] ?? null;
if ($identifier === null) {
throw new \RuntimeException('Site identifier to edit must be set', 1724772561);
}
$site = $this->siteFinder->getSiteByIdentifier($identifier);
$parsedBody = $request->getParsedBody();
$specificSetting = (string)($parsedBody['specificSetting'] ?? '');
$minify = $specificSetting !== '' ? false : true;
$newSettings = $this->siteSettingsService->createSettingsFromFormData($site, $parsedBody['settings'] ?? []);
$settingsDiff = $this->siteSettingsService->computeSettingsDiff($site, $newSettings, $minify);
$settings = $settingsDiff->asArray();
if ($specificSetting !== '' && isset($settings[$specificSetting])) {
$settings = [
$specificSetting => $settings[$specificSetting],
];
}
$yamlContents = Yaml::dump($settings, 99, 2, Yaml::DUMP_EMPTY_ARRAY_AS_SEQUENCE | Yaml::DUMP_OBJECT_AS_MAP);
return new JsonResponse([
'yaml' => $yamlContents,
]);
}
protected function addDocHeaderBreadcrumb(ModuleTemplate $moduleTemplate, Site $site): void
{
$record = BackendUtility::getRecord('pages', $site->getRootPageId());
$moduleTemplate->getDocHeaderComponent()->setPageBreadcrumb($record ?? []);
}
protected function addDocHeaderCloseAndSaveButtons(ModuleTemplate $moduleTemplate, string $closeUrl, bool $saveEnabled): void
{
$moduleTemplate->addButtonToButtonBar($this->componentFactory->createCloseButton($closeUrl));
$saveButton = $this->componentFactory->createSaveButton('sitesettings_form')
->setName('CMD')
->setValue('save')
->setDisabled(!$saveEnabled);
$moduleTemplate->addButtonToButtonBar($saveButton, ButtonBar::BUTTON_POSITION_LEFT, 2);
}
protected function addDocHeaderViewModeButton(ModuleTemplate $moduleTemplate, Site $site, SetupSettingsViewMode $mode): void
{
$languageService = $this->getLanguageService();
$viewModeButton = $this->componentFactory->createDropDownButton()
->setLabel($languageService->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.view'))
->setShowLabelText(true);
$viewModeButton->addItem(
$this->componentFactory->createDropDownRadio()
->setActive(($mode === SetupSettingsViewMode::BASIC))
->setHref(
(string)$this->uriBuilder->buildUriFromRoute(
'site_configuration.editSettings',
array_filter([
'site' => $site->getIdentifier(),
'settingsMode' => SetupSettingsViewMode::BASIC->value,
])
)
)
->setLabel($languageService->sL('LLL:EXT:backend/Resources/Private/Language/locallang_settingseditor.xlf:settingseditor.mode.basic'))
->setIcon($this->iconFactory->getIcon('actions-window', IconSize::SMALL))
);
$viewModeButton->addItem(
$this->componentFactory->createDropDownRadio()
->setActive(($mode === SetupSettingsViewMode::ADVANCED))
->setHref(
(string)$this->uriBuilder->buildUriFromRoute(
'site_configuration.editSettings',
array_filter([
'site' => $site->getIdentifier(),
'settingsMode' => SetupSettingsViewMode::ADVANCED->value,
])
)
)
->setLabel($languageService->sL('LLL:EXT:backend/Resources/Private/Language/locallang_settingseditor.xlf:settingseditor.mode.advanced'))
->setIcon($this->iconFactory->getIcon('actions-window-cog', IconSize::SMALL))
);
$moduleTemplate->addButtonToButtonBar($viewModeButton, ButtonBar::BUTTON_POSITION_RIGHT, 2);
}
protected function addDocHeaderExportButton(ModuleTemplate $moduleTemplate, SetupSettingsViewMode $mode): void
{
if ($mode === SetupSettingsViewMode::ADVANCED) {
$languageService = $this->getLanguageService();
$exportButton = $this->componentFactory->createInputButton()
->setTitle($languageService->sL('LLL:EXT:backend/Resources/Private/Language/locallang_sitesettings.xlf:edit.yamlExport'))
->setIcon($this->iconFactory->getIcon('actions-database-export', IconSize::SMALL))
->setShowLabelText(true)
->setName('CMD')
->setValue('export')
->setForm('sitesettings_form');
$moduleTemplate->addButtonToButtonBar($exportButton, ButtonBar::BUTTON_POSITION_RIGHT);
}
}
protected function addDocHeaderSiteConfigurationButton(ModuleTemplate $moduleTemplate, Site $site): void
{
$languageService = $this->getLanguageService();
$exportButton = $this->componentFactory->createLinkButton()
->setTitle($languageService->sL('LLL:EXT:backend/Resources/Private/Language/locallang_sitesettings.xlf:edit.editSiteConfiguration'))
->setIcon($this->iconFactory->getIcon('actions-open', IconSize::SMALL))
->setShowLabelText(true)
->setHref((string)$this->uriBuilder->buildUriFromRoute('site_configuration.edit', [
'site' => $site->getIdentifier(),
'returnUrl' => $this->uriBuilder->buildUriFromRoute('site_configuration.editSettings', [
'site' => $site->getIdentifier(),
]),
]));
$moduleTemplate->addButtonToButtonBar($exportButton, ButtonBar::BUTTON_POSITION_LEFT, 3);
}
protected function getSiteTitle(Site $site): string
{
$websiteTitle = $site->getConfiguration()['websiteTitle'] ?? '';
if ($websiteTitle !== '') {
return $websiteTitle;
}
$rootPage = BackendUtility::getRecord('pages', $site->getRootPageId());
$title = $rootPage['title'] ?? '';
if ($title !== '') {
return $title;
}
return '(unknown)';
}
protected function getLanguageService(): LanguageService
{
return $GLOBALS['LANG'];
}
protected function getBackendUser(): BackendUserAuthentication
{
return $GLOBALS['BE_USER'];
}
}
@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
/*
* 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 TYPO3\CMS\Backend\Controller;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Core\Http\HtmlResponse;
/**
* @internal This class is a specific Backend controller implementation and is not considered part of the Public TYPO3 API.
*/
class StateTrackerController
{
public function mainAction(ServerRequestInterface $request): ResponseInterface
{
return new HtmlResponse('');
}
}
@@ -0,0 +1,109 @@
<?php
declare(strict_types=1);
/*
* 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 TYPO3\CMS\Backend\Controller;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Backend\Attribute\AsController;
use TYPO3\CMS\Backend\Module\ModuleInterface;
use TYPO3\CMS\Backend\Module\ModuleProvider;
use TYPO3\CMS\Backend\Template\Enum\ModuleLayout;
use TYPO3\CMS\Backend\Template\ModuleTemplateFactory;
use TYPO3\CMS\Backend\Utility\BackendUtility;
use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
use TYPO3\CMS\Core\Localization\LanguageService;
use TYPO3\CMS\Core\Type\Bitmask\Permission;
/**
* Controller for displaying a card-based overview of available submodules.
* This provides a user-friendly way to navigate between third-level modules.
*
* @internal This class is a specific Backend controller implementation and is not part of the TYPO3's Core API.
*/
#[AsController]
final readonly class SubmoduleOverviewController
{
public function __construct(
private ModuleTemplateFactory $moduleTemplateFactory,
private ModuleProvider $moduleProvider,
) {}
/**
* Main action that displays the submodule overview cards
*/
public function handleRequest(ServerRequestInterface $request): ResponseInterface
{
$view = $this->moduleTemplateFactory->create($request);
$currentModule = $request->getAttribute('module');
if (!($currentModule instanceof ModuleInterface) || !$this->moduleProvider->accessGranted($currentModule->getIdentifier(), $this->getBackendUser())) {
return $view->renderResponse('SubmoduleOverview/Cards');
}
$id = (int)($request->getParsedBody()['id'] ?? $request->getQueryParams()['id'] ?? 0);
$pageinfo = BackendUtility::readPageAccess($id, $this->getBackendUser()->getPagePermsClause(Permission::PAGE_SHOW)) ?: [];
$view->setTitle(
$this->getLanguageService()->sL($currentModule->getTitle()),
$id !== 0 && isset($pageinfo['title']) ? $pageinfo['title'] : ''
);
if ($pageinfo !== []) {
$view->getDocHeaderComponent()->setPageBreadcrumb($pageinfo);
}
$view->makeDocHeaderModuleMenu(['id' => $id]);
$view->getDocHeaderComponent()->setShortcutContext(
$currentModule->getIdentifier(),
$this->getLanguageService()->sL($currentModule->getTitle())
);
$view->setTitle($this->getLanguageService()->sL($currentModule->getTitle()));
$view->setLayout(ModuleLayout::NORMAL);
$view->assign('currentModule', $currentModule);
$view->assignMultiple([
'additionalParameters' => array_filter(['id' => $id]),
'submodules' => $this->getAccessibleSubmodules($currentModule),
'moduleTitle' => $this->getLanguageService()->sL($currentModule->getTitle()),
]);
return $view->renderResponse('SubmoduleOverview/Cards');
}
/**
* Get all submodules the current user has access to
*
* @return ModuleInterface[]
*/
private function getAccessibleSubmodules(ModuleInterface $module): array
{
$accessibleSubmodules = [];
foreach ($module->getSubModules() as $submodule) {
// Check if the user has access to this submodule
if ($this->moduleProvider->accessGranted($submodule->getIdentifier(), $this->getBackendUser())) {
$accessibleSubmodules[] = $submodule;
}
}
return $accessibleSubmodules;
}
private function getLanguageService(): LanguageService
{
return $GLOBALS['LANG'];
}
private function getBackendUser(): BackendUserAuthentication
{
return $GLOBALS['BE_USER'];
}
}
+179
View File
@@ -0,0 +1,179 @@
<?php
declare(strict_types=1);
/*
* 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 TYPO3\CMS\Backend\Controller;
use Psr\EventDispatcher\EventDispatcherInterface;
use Psr\Http\Message\ResponseFactoryInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Backend\Attribute\AsController;
use TYPO3\CMS\Backend\Authentication\Event\SwitchUserEvent;
use TYPO3\CMS\Backend\Routing\UriBuilder;
use TYPO3\CMS\Backend\Utility\BackendUtility;
use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
use TYPO3\CMS\Core\Session\Backend\SessionBackendInterface;
use TYPO3\CMS\Core\Session\SessionManager;
use TYPO3\CMS\Core\SysLog\Type;
use TYPO3\CMS\Core\Utility\ExtensionManagementUtility;
/**
* @internal This class is a specific Backend controller implementation and is not considered part of the Public TYPO3 API.
*/
#[AsController]
class SwitchUserController
{
protected const RECENT_USERS_LIMIT = 3;
protected EventDispatcherInterface $eventDispatcher;
protected UriBuilder $uriBuilder;
protected ResponseFactoryInterface $responseFactory;
protected SessionBackendInterface $sessionBackend;
public function __construct(
EventDispatcherInterface $eventDispatcher,
UriBuilder $uriBuilder,
ResponseFactoryInterface $responseFactory,
SessionManager $sessionManager
) {
$this->eventDispatcher = $eventDispatcher;
$this->uriBuilder = $uriBuilder;
$this->responseFactory = $responseFactory;
$this->sessionBackend = $sessionManager->getSessionBackend('BE');
}
/**
* Handle switching current user to the requested target user
*/
public function switchUserAction(ServerRequestInterface $request): ResponseInterface
{
$currentUser = $this->getBackendUserAuthentication();
$targetUserId = (int)($request->getParsedBody()['targetUser'] ?? 0);
if (!$targetUserId
|| !$currentUser->isAdmin()
|| $targetUserId === $currentUser->getUserId()
|| $currentUser->getOriginalUserIdWhenInSwitchUserMode() !== null
) {
return $this->jsonResponse(['success' => false]);
}
$targetUser = BackendUtility::getRecord('be_users', $targetUserId, '*', BackendUtility::BEenableFields('be_users'));
if ($targetUser === null) {
return $this->jsonResponse(['success' => false]);
}
if (ExtensionManagementUtility::isLoaded('beuser')) {
// Set backend user listing module as starting module if installed
$currentUser->uc['startModuleOnFirstLogin'] = 'backend_user_management';
}
$currentUser->uc['recentSwitchedToUsers'] = $this->generateListOfMostRecentSwitchedUsers($targetUserId);
$currentUser->writeUC();
// Write user switch to log
$currentUser->writelog(Type::LOGIN, 2, 0, null, 'User %s switched to user %s (be_users:%s)', [
$currentUser->getUserName() ?? '',
$targetUser['username'] ?? '',
$targetUserId,
]);
$sessionObject = $currentUser->getSession();
$sessionObject->set('backuserid', $currentUser->getUserId() ?? 0);
$sessionRecord = $sessionObject->toArray();
$sessionRecord['ses_userid'] = $targetUserId;
$this->sessionBackend->update($sessionObject->getIdentifier(), $sessionRecord);
// We must regenerate the internal session so the new ses_userid is present in the userObject
$currentUser->enforceNewSessionId();
$event = new SwitchUserEvent(
$currentUser->getSession()->getIdentifier(),
$targetUser,
(array)$currentUser->user
);
$this->eventDispatcher->dispatch($event);
return $this->jsonResponse([
'success' => true,
'url' => (string)$this->uriBuilder->buildUriFromRoute('main'),
]);
}
/**
* Handle exiting the switch user mode
*/
public function exitSwitchUserAction(ServerRequestInterface $request): ResponseInterface
{
$currentUser = $this->getBackendUserAuthentication();
if ($currentUser->getOriginalUserIdWhenInSwitchUserMode() === null) {
return $this->jsonResponse(['success' => false]);
}
$sessionObject = $currentUser->getSession();
$originalUser = (int)$sessionObject->get('backuserid');
$sessionObject->set('backuserid', null);
$sessionRecord = $sessionObject->toArray();
$sessionRecord['ses_userid'] = $originalUser;
$this->sessionBackend->update($sessionObject->getIdentifier(), $sessionRecord);
// We must regenerate the internal session so the new ses_userid is present in the userObject
$currentUser->enforceNewSessionId();
return $this->jsonResponse([
'success' => true,
'url' => (string)$this->uriBuilder->buildUriFromRoute('main'),
]);
}
/**
* Generates a list of users to whom where switched in the past. This is limited by RECENT_USERS_LIMIT.
*
* @return int[]
*/
protected function generateListOfMostRecentSwitchedUsers(int $targetUserUid): array
{
$latestUserUids = [];
$backendUser = $this->getBackendUserAuthentication();
if (isset($backendUser->uc['recentSwitchedToUsers']) && is_array($backendUser->uc['recentSwitchedToUsers'])) {
$latestUserUids = $backendUser->uc['recentSwitchedToUsers'];
}
// Remove potentially existing user in that list
$index = array_search($targetUserUid, $latestUserUids, true);
if ($index !== false) {
unset($latestUserUids[$index]);
}
array_unshift($latestUserUids, $targetUserUid);
return array_slice($latestUserUids, 0, static::RECENT_USERS_LIMIT);
}
protected function jsonResponse(array $data): ResponseInterface
{
$response = $this->responseFactory
->createResponse()
->withAddedHeader('Content-Type', 'application/json; charset=utf-8');
$response->getBody()->write(json_encode($data));
return $response;
}
protected function getBackendUserAuthentication(): BackendUserAuthentication
{
return $GLOBALS['BE_USER'];
}
}
@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
/*
* 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 TYPO3\CMS\Backend\Controller;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Backend\Attribute\AsController;
use TYPO3\CMS\Backend\Backend\ToolbarItems\SystemInformationToolbarItem;
use TYPO3\CMS\Core\Http\HtmlResponse;
/**
* Controller for system information processing. Used as ajax end point to update drop down
*
* @internal This class is a specific Backend controller implementation and is not considered part of the Public TYPO3 API.
*/
#[AsController]
readonly class SystemInformationController
{
public function __construct(
private SystemInformationToolbarItem $systemInformationToolbarItem,
) {}
/**
* Renders the menu for AJAX calls
*/
public function renderMenuAction(ServerRequestInterface $request): ResponseInterface
{
$this->systemInformationToolbarItem->setRequest($request);
return new HtmlResponse($this->systemInformationToolbarItem->getDropDown());
}
}
@@ -0,0 +1,87 @@
<?php
declare(strict_types=1);
/*
* 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 TYPO3\CMS\Backend\Controller;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Backend\Configuration\BackendUserConfiguration;
use TYPO3\CMS\Core\Http\JsonResponse;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* A wrapper class to call BE_USER->uc
* used for AJAX and Storage/Persistent JS object
* @internal This class is a specific Backend controller implementation and is not considered part of the Public TYPO3 API.
*/
class UserSettingsController
{
private const array ALLOWED_ACTIONS = [
'GET' => ['get', 'getAll'],
'POST' => ['set', 'addToList', 'removeFromList', 'unset', 'clear'],
];
/**
* Processes all AJAX calls and returns a JSON for the data
*/
public function processAjaxRequest(ServerRequestInterface $request): ResponseInterface
{
// do the regular / main logic, depending on the action parameter
$action = $this->getValidActionFromRequest($request);
$key = $request->getParsedBody()['key'] ?? $request->getQueryParams()['key'] ?? '';
$value = $request->getParsedBody()['value'] ?? $request->getQueryParams()['value'] ?? '';
$backendUserConfiguration = GeneralUtility::makeInstance(BackendUserConfiguration::class);
switch ($action) {
case 'get':
$content = $backendUserConfiguration->get($key);
break;
case 'getAll':
$content = $backendUserConfiguration->getAll();
break;
case 'set':
$backendUserConfiguration->set($key, $value);
$content = $backendUserConfiguration->getAll();
break;
case 'addToList':
$backendUserConfiguration->addToList($key, $value);
$content = $backendUserConfiguration->getAll();
break;
case 'removeFromList':
$backendUserConfiguration->removeFromList($key, $value);
$content = $backendUserConfiguration->getAll();
break;
case 'unset':
$backendUserConfiguration->unsetOption($key);
$content = $backendUserConfiguration->getAll();
break;
case 'clear':
$backendUserConfiguration->clear();
$content = ['result' => true];
break;
default:
$content = ['result' => false];
}
return new JsonResponse($content);
}
protected function getValidActionFromRequest(ServerRequestInterface $request): string
{
$action = $request->getParsedBody()['action'] ?? $request->getQueryParams()['action'] ?? '';
return in_array($action, (self::ALLOWED_ACTIONS[$request->getMethod()] ?? []), true) ? $action : '';
}
}
+249
View File
@@ -0,0 +1,249 @@
<?php
declare(strict_types=1);
/*
* 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 TYPO3\CMS\Backend\Controller\Wizard;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Backend\Attribute\AsController;
use TYPO3\CMS\Backend\Form\FormDataCompiler;
use TYPO3\CMS\Backend\Form\FormDataGroup\TcaDatabaseRecord;
use TYPO3\CMS\Backend\Form\Utility\FormEngineUtility;
use TYPO3\CMS\Backend\Routing\UriBuilder;
use TYPO3\CMS\Backend\Utility\BackendUtility;
use TYPO3\CMS\Core\DataHandling\DataHandler;
use TYPO3\CMS\Core\Http\RedirectResponse;
use TYPO3\CMS\Core\Utility\ArrayUtility;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Core\Utility\MathUtility;
/**
* Script Class for adding new items to a group/select field. Performs proper redirection as needed.
* Script is typically called after new child record was added and then adds the new child to select value of parent.
*
* @internal This class is a specific Backend controller implementation and is not considered part of the Public TYPO3 API.
*/
#[AsController]
class AddController
{
/**
* If set, the DataHandler class is loaded and used to add the returning ID to the parent record.
*/
protected int $processDataFlag = 0;
/**
* Create new record -pid (pos/neg). If blank, return immediately
*/
protected int $pid = 0;
/**
* The parent table we are working on.
*/
protected string $table = '';
/**
* Loaded with the created id of a record FormEngine returns ...
*/
protected int $id = 0;
/**
* Wizard parameters, coming from TCEforms linking to the wizard.
*/
protected array $P = [];
/**
* Information coming back from the FormEngine script, telling what the table/id was of the newly created record.
*/
protected string $returnEditConf = '';
public function __construct(
private readonly FormDataCompiler $formDataCompiler,
private readonly UriBuilder $uriBuilder,
) {}
/**
* Injects the request object for the current request or subrequest
* As this controller goes only through the main() method, it is rather simple for now
*/
public function mainAction(ServerRequestInterface $request): ResponseInterface
{
$this->init($request);
if ($this->returnEditConf) {
if ($this->processDataFlag) {
// Because OnTheFly can't handle MM relations with intermediate tables we use TcaDatabaseRecord here
// Otherwise already stored relations are overwritten with the new entry
$input = [
'request' => $request,
'tableName' => $this->P['table'],
'vanillaUid' => (int)$this->P['uid'],
'command' => 'edit',
];
$result = $this->formDataCompiler->compile($input, GeneralUtility::makeInstance(TcaDatabaseRecord::class));
$currentParentRow = $result['databaseRow'];
// If that record was found (should absolutely be...), then init DataHandler and set, prepend or append
// the record
if (is_array($currentParentRow)) {
$dataHandler = GeneralUtility::makeInstance(DataHandler::class);
$data = [];
$recordId = $this->table . '_' . $this->id;
// Setting the new field data:
// If the field is a flexForm field, work with the XML structure instead:
if ($this->P['flexFormPath']) {
// Current value of flexForm path:
$currentFlexFormData = $currentParentRow[$this->P['field']];
$currentFlexFormValueByPath = ArrayUtility::getValueByPath($currentFlexFormData, $this->P['flexFormPath']);
// Compile currentFlexFormData to functional string
$currentFlexFormValues = [];
foreach ($currentFlexFormValueByPath as $value) {
if (is_array($value)) {
// group fields are always resolved to array
$currentFlexFormValues[] = $value['table'] . '_' . $value['uid'];
} else {
// but select fields may be uids only
$currentFlexFormValues[] = $value;
}
}
$currentFlexFormValue = implode(',', $currentFlexFormValues);
$insertValue = '';
switch ((string)$this->P['params']['setValue']) {
case 'set':
$insertValue = $recordId;
break;
case 'append':
$insertValue = $currentFlexFormValue . ',' . $recordId;
break;
case 'prepend':
$insertValue = $recordId . ',' . $currentFlexFormValue;
break;
}
$insertValue = implode(',', GeneralUtility::trimExplode(',', $insertValue, true));
$data[$this->P['table']][$this->P['uid']][$this->P['field']] = ArrayUtility::setValueByPath([], $this->P['flexFormPath'], $insertValue);
} else {
$currentValue = $currentParentRow[$this->P['field']];
// Normalize CSV values
if (!is_array($currentValue)) {
$currentValue = GeneralUtility::trimExplode(',', $currentValue, true);
}
// Normalize all items to "<table>_<uid>" format
$currentValue = array_map(function (array|int|string $item): string {
// Handle per-item table for "group" elements
if (is_array($item)) {
$item = $item['table'] . '_' . $item['uid'];
} else {
$item = $this->table . '_' . $item;
}
return $item;
}, $currentValue);
switch ((string)$this->P['params']['setValue']) {
case 'set':
$currentValue = [$recordId];
break;
case 'append':
$currentValue[] = $recordId;
break;
case 'prepend':
array_unshift($currentValue, $recordId);
break;
}
$data[$this->P['table']][$this->P['uid']][$this->P['field']] = implode(',', $currentValue);
}
// Submit the data:
$dataHandler->start($data, []);
$dataHandler->process_datamap();
}
}
// Return to the parent FormEngine record editing session:
return new RedirectResponse(GeneralUtility::sanitizeLocalUrl($this->P['returnUrl'], $request));
}
// Redirecting to FormEngine with instructions to create a new record
// AND when closing to return back with information about that records ID etc.
$normalizedParams = $request->getAttribute('normalizedParams');
$redirectUrl = (string)$this->uriBuilder->buildUriFromRoute('record_edit', [
'returnEditConf' => 1,
'edit[' . $this->P['params']['table'] . '][' . $this->pid . ']' => 'new',
// @todo add module context to wizard/add routes and set module context here
'returnUrl' => $normalizedParams->getRequestUri(),
]);
return new RedirectResponse($redirectUrl);
}
/**
* Initialization of the class.
*/
protected function init(ServerRequestInterface $request): void
{
$parsedBody = $request->getParsedBody();
$queryParams = $request->getQueryParams();
// Init GPvars:
$this->P = $parsedBody['P'] ?? $queryParams['P'] ?? [];
$this->returnEditConf = $parsedBody['returnEditConf'] ?? $queryParams['returnEditConf'] ?? '';
// Get this record
$record = BackendUtility::getRecord($this->P['table'], $this->P['uid']);
// Set table:
$this->table = $this->P['params']['table'];
// Get TSconfig for it.
$TSconfig = FormEngineUtility::getTCEFORM_TSconfig(
$this->P['table'],
is_array($record) ? $record : ['pid' => (int)$this->P['params']['pid']]
);
// Set [params][pid]
if (str_starts_with($this->P['params']['pid'], '###') && str_ends_with($this->P['params']['pid'], '###')) {
$keyword = substr($this->P['params']['pid'], 3, -3);
$this->pid = str_starts_with($keyword, 'PAGE_TSCONFIG_')
? (int)$TSconfig[$this->P['field']][$keyword]
: (int)$TSconfig['_' . $keyword];
} else {
$this->pid = (int)$this->P['params']['pid'];
}
// If a new id has returned from a newly created record...
if ($this->returnEditConf) {
$editConfiguration = json_decode($this->returnEditConf, true);
if (is_array($editConfiguration[$this->table]) && MathUtility::canBeInterpretedAsInteger($this->P['uid'])) {
// Getting id and cmd from returning editConf array.
reset($editConfiguration[$this->table]);
$this->id = (int)key($editConfiguration[$this->table]);
$cmd = current($editConfiguration[$this->table]);
// ... and if everything seems OK we will register some classes for inclusion and instruct the object
// to perform processing later.
if ($this->P['params']['setValue']
&& $cmd === 'edit'
&& $this->id
&& $this->P['table']
&& $this->P['field'] && $this->P['uid']
) {
$liveRecord = BackendUtility::getLiveVersionOfRecord($this->table, $this->id, 'uid');
if ($liveRecord) {
$this->id = $liveRecord['uid'];
}
$this->processDataFlag = 1;
}
}
}
}
}
@@ -0,0 +1,179 @@
<?php
declare(strict_types=1);
/*
* 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 TYPO3\CMS\Backend\Controller\Wizard;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Backend\Attribute\AsController;
use TYPO3\CMS\Backend\Routing\UriBuilder;
use TYPO3\CMS\Core\Configuration\FlexForm\FlexFormTools;
use TYPO3\CMS\Core\Database\RelationHandler;
use TYPO3\CMS\Core\Http\HtmlResponse;
use TYPO3\CMS\Core\Http\RedirectResponse;
use TYPO3\CMS\Core\Schema\TcaSchemaFactory;
use TYPO3\CMS\Core\Utility\ArrayUtility;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Core\Utility\MathUtility;
use TYPO3\CMS\Core\Utility\PathUtility;
/**
* Script Class for redirecting a backend user to the editing form when an "Edit wizard" link was clicked in FormEngine somewhere.
*
* @internal This class is a specific Backend controller implementation and is not considered part of the Public TYPO3 API.
*/
#[AsController]
class EditController
{
protected const JAVASCRIPT_HELPER = 'EXT:backend/Resources/Public/JavaScript/helper.js';
/**
* Wizard parameters, coming from FormEngine linking to the wizard.
*
* Contains the following parts:
* - table
* - field
* - formName
* - hmac
* - fieldChangeFunc
* - fieldChangeFuncHash
* - currentValue
* - currentSelectedValues
*
* @var array
*/
protected $P;
/**
* Boolean; if set, the window will be closed by JavaScript
*
* @var int
*/
protected $doClose;
/**
* HTML markup to close the open window.
*/
protected string $closeWindow;
public function __construct(
private readonly FlexFormTools $flexFormTools,
private readonly TcaSchemaFactory $tcaSchemaFactory,
private readonly UriBuilder $uriBuilder,
) {}
/**
* Injects the request object for the current request or subrequest
* As this controller goes only through the main() method, it is rather simple for now
*/
public function mainAction(ServerRequestInterface $request): ResponseInterface
{
$this->closeWindow = sprintf(
'<script %s></script>',
GeneralUtility::implodeAttributes([
'src' => (string)PathUtility::getSystemResourceUri(self::JAVASCRIPT_HELPER, $request),
'data-action' => 'window.close',
], true)
);
$parsedBody = $request->getParsedBody();
$queryParams = $request->getQueryParams();
$this->P = $parsedBody['P'] ?? $queryParams['P'] ?? [];
// Used for the return URL to FormEngine so that we can close the window.
$this->doClose = $parsedBody['doClose'] ?? $queryParams['doClose'] ?? 0;
return $this->processRequest();
}
/**
* Process request function
* Makes a header-location redirect to an edit form IF POSSIBLE from the passed data - otherwise the window will
* just close.
*/
protected function processRequest(): ResponseInterface
{
if ($this->doClose) {
return new HtmlResponse($this->closeWindow);
}
// Initialize:
$table = $this->P['table'];
$field = $this->P['field'];
$schema = $this->tcaSchemaFactory->get($table);
if (empty($this->P['flexFormDataStructureIdentifier'])) {
// If there is not flex data structure identifier, field config is found in globals
$config = $schema->getField($field)->getConfiguration();
} else {
// If there is a flex data structure identifier, parse that data structure and
// fetch config defined by given flex path
$dataStructure = $this->flexFormTools->parseDataStructureByIdentifier($this->P['flexFormDataStructureIdentifier'], $schema);
$config = ArrayUtility::getValueByPath($dataStructure, $this->P['flexFormDataStructurePath']);
if (!is_array($config)) {
throw new \RuntimeException(
'Something went wrong finding flex path ' . $this->P['flexFormDataStructurePath']
. ' in data structure identified by ' . $this->P['flexFormDataStructureIdentifier'],
1537356346
);
}
}
$urlParameters = [
'returnUrl' => (string)$this->uriBuilder->buildUriFromRoute('wizard_edit', ['doClose' => 1]),
];
// Detecting the various allowed field type setups and acting accordingly.
if ($config['type'] === 'select'
&& !($config['MM'] ?? false)
&& (int)($config['maxitems'] ?? 0) <= 1
&& MathUtility::canBeInterpretedAsInteger($this->P['currentValue'])
&& $this->P['currentValue']
&& $config['foreign_table']
) {
// SINGLE value
$urlParameters['edit[' . $config['foreign_table'] . '][' . $this->P['currentValue'] . ']'] = 'edit';
// Redirect to FormEngine
// Note: no 'module' context here, since we're opening in a popup
$url = $this->uriBuilder->buildUriFromRoute('record_edit', $urlParameters);
return new RedirectResponse($url);
}
if (!empty($config['type'])
&& !empty($this->P['currentSelectedValues'])
&& (
$config['type'] === 'select' && !empty($config['foreign_table'])
|| $config['type'] === 'group' && !empty($config['allowed'])
)
) {
// MULTIPLE VALUES:
// Init settings:
$allowedTables = $config['type'] === 'group' ? $config['allowed'] : $config['foreign_table'];
// Selecting selected values into an array:
$relationHandler = GeneralUtility::makeInstance(RelationHandler::class);
$relationHandler->start($this->P['currentSelectedValues'], $allowedTables);
$value = $relationHandler->getValueArray(true);
// Traverse that array and make parameters for FormEngine
foreach ($value as $rec) {
$recTableUidParts = GeneralUtility::revExplode('_', $rec, 2);
$urlParameters['edit[' . $recTableUidParts[0] . '][' . $recTableUidParts[1] . ']'] = 'edit';
}
// Redirect to FormEngine
$url = $this->uriBuilder->buildUriFromRoute('record_edit', $urlParameters);
return new RedirectResponse($url);
}
return new HtmlResponse($this->closeWindow);
}
}
@@ -0,0 +1,78 @@
<?php
declare(strict_types=1);
/*
* 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 TYPO3\CMS\Backend\Controller\Wizard;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Backend\Attribute\AsController;
use TYPO3\CMS\Backend\View\BackendViewFactory;
use TYPO3\CMS\Core\Crypto\HashService;
use TYPO3\CMS\Core\Http\HtmlResponse;
use TYPO3\CMS\Core\Resource\Exception\FileDoesNotExistException;
use TYPO3\CMS\Core\Resource\ResourceFactory;
use TYPO3\CMS\Core\Utility\MathUtility;
/**
* Wizard for rendering image manipulation view
* @internal This class is a specific Backend controller implementation and is not considered part of the Public TYPO3 API.
*/
#[AsController]
readonly class ImageManipulationController
{
public function __construct(
protected BackendViewFactory $backendViewFactory,
protected HashService $hashService,
protected ResourceFactory $resourceFactory,
) {}
/**
* Returns the HTML for the wizard inside the modal
*/
public function getWizardContent(ServerRequestInterface $request): ResponseInterface
{
if ($this->isSignatureValid($request)) {
$parsedBody = json_decode($request->getParsedBody()['arguments'], true);
$fileUid = $parsedBody['image'];
$image = null;
if (MathUtility::canBeInterpretedAsInteger($fileUid)) {
try {
$image = $this->resourceFactory->getFileObject($fileUid);
} catch (FileDoesNotExistException $e) {
}
}
$view = $this->backendViewFactory->create($request);
$view->assignMultiple([
'image' => $image,
'cropVariants' => $parsedBody['cropVariants'],
]);
return new HtmlResponse($view->render('Form/ImageManipulationWizard'));
}
return new HtmlResponse('', 403);
}
/**
* Check if hmac signature is correct
*
* @param ServerRequestInterface $request the request with the POST parameters
*/
protected function isSignatureValid(ServerRequestInterface $request): bool
{
$token = $this->hashService->hmac($request->getParsedBody()['arguments'], 'ajax_wizard_image_manipulation');
return hash_equals($token, $request->getParsedBody()['signature']);
}
}
@@ -0,0 +1,80 @@
<?php
declare(strict_types=1);
/*
* 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 TYPO3\CMS\Backend\Controller\Wizard;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Backend\Form\Utility\FormEngineUtility;
use TYPO3\CMS\Backend\Routing\UriBuilder;
use TYPO3\CMS\Backend\Utility\BackendUtility;
use TYPO3\CMS\Core\Http\RedirectResponse;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Script Class for redirecting the user to the Content > Records module if a wizard-link has been clicked in FormEngine.
*
* @internal This class is a specific Backend controller implementation and is not considered part of the Public TYPO3 API.
*/
class ListController
{
/**
* Injects the request object for the current request or sub request
*/
public function mainAction(ServerRequestInterface $request): ResponseInterface
{
$parsedBody = $request->getParsedBody();
$queryParams = $request->getQueryParams();
// Wizard parameters, coming from FormEngine linking to this wizard.
$parameters = $parsedBody['P'] ?? $queryParams['P'] ?? null;
$id = $parsedBody['id'] ?? $queryParams['id'] ?? null;
$table = $parameters['table'] ?? '';
$origRow = BackendUtility::getRecord($table, $parameters['uid']);
$tsConfig = FormEngineUtility::getTCEFORM_TSconfig($table, $origRow ?? ['pid' => $parameters['pid'] ?? 0]);
if (str_starts_with($parameters['params']['pid'], '###') && substr($parameters['params']['pid'], -3) === '###') {
$keyword = substr($parameters['params']['pid'], 3, -3);
if (str_starts_with($keyword, 'PAGE_TSCONFIG_')) {
$pid = (int)$tsConfig[$parameters['field']][$keyword];
} else {
$pid = (int)$tsConfig['_' . $keyword];
}
} else {
$pid = (int)$parameters['params']['pid'];
}
if ((string)$id !== '') {
// If pid is blank
$redirectUrl = GeneralUtility::sanitizeLocalUrl($parameters['returnUrl'], $request);
} else {
// Otherwise, show the list
$uriBuilder = GeneralUtility::makeInstance(UriBuilder::class);
$normalizedParams = $request->getAttribute('normalizedParams');
$requestUri = $normalizedParams->getRequestUri();
$urlParameters = [];
$urlParameters['id'] = $pid;
$urlParameters['table'] = $parameters['params']['table'];
$urlParameters['returnUrl'] = !empty($parameters['returnUrl'])
? GeneralUtility::sanitizeLocalUrl($parameters['returnUrl'], $request)
: $requestUri;
$redirectUrl = (string)$uriBuilder->buildUriFromRoute('records', $urlParameters);
}
return new RedirectResponse($redirectUrl);
}
}
@@ -0,0 +1,779 @@
<?php
declare(strict_types=1);
/*
* 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 TYPO3\CMS\Backend\Controller\Wizard;
use Psr\EventDispatcher\EventDispatcherInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Symfony\Component\DependencyInjection\Attribute\Autoconfigure;
use TYPO3\CMS\Backend\Configuration\TranslationConfigurationProvider;
use TYPO3\CMS\Backend\Controller\Event\AfterPageColumnsSelectedForLocalizationEvent;
use TYPO3\CMS\Backend\Controller\Event\AfterRecordSummaryForLocalizationEvent;
use TYPO3\CMS\Backend\Domain\Repository\Localization\LocalizationRepository;
use TYPO3\CMS\Backend\Localization\LocalizationHandlerInterface;
use TYPO3\CMS\Backend\Localization\LocalizationHandlerRegistry;
use TYPO3\CMS\Backend\Localization\LocalizationInstructions;
use TYPO3\CMS\Backend\Localization\LocalizationMode;
use TYPO3\CMS\Backend\Localization\LocalizationResult;
use TYPO3\CMS\Backend\Utility\BackendUtility;
use TYPO3\CMS\Backend\View\BackendLayoutView;
use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
use TYPO3\CMS\Core\Database\Connection;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Database\Query\Restriction\DeletedRestriction;
use TYPO3\CMS\Core\Database\Query\Restriction\WorkspaceRestriction;
use TYPO3\CMS\Core\Http\JsonResponse;
use TYPO3\CMS\Core\Http\Response;
use TYPO3\CMS\Core\Imaging\IconFactory;
use TYPO3\CMS\Core\Imaging\IconSize;
use TYPO3\CMS\Core\Localization\LanguageService;
use TYPO3\CMS\Core\Schema\Capability\TcaSchemaCapability;
use TYPO3\CMS\Core\Schema\TcaSchemaFactory;
use TYPO3\CMS\Core\Type\Bitmask\Permission;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Core\Versioning\VersionState;
/**
* LocalizationController handles the AJAX requests for the localization wizard.
*
* @internal This class is a specific Backend controller implementation and is not considered part of the Public TYPO3 API.
*/
#[Autoconfigure(public: true)]
readonly class LocalizationController
{
public function __construct(
protected IconFactory $iconFactory,
protected LocalizationRepository $localizationRepository,
protected EventDispatcherInterface $eventDispatcher,
protected LocalizationHandlerInterface $localizationHandler,
protected LocalizationHandlerRegistry $localizationHandlerRegistry,
protected TcaSchemaFactory $schemaFactory,
protected TranslationConfigurationProvider $translationConfigurationProvider,
protected BackendLayoutView $backendLayoutView,
protected ConnectionPool $connectionPool,
) {}
/**
* Get record information for localization wizard
*/
public function getRecord(ServerRequestInterface $request): ResponseInterface
{
$params = $request->getQueryParams();
if (!isset($params['recordType'], $params['recordUid'])) {
return new JsonResponse(null, 400);
}
$recordType = $params['recordType'];
$recordUid = (int)$params['recordUid'];
$record = BackendUtility::getRecord($recordType, $recordUid);
if (!$record) {
return new JsonResponse(null, 404);
}
$schema = $this->schemaFactory->get($recordType);
$recordTitle = BackendUtility::getRecordTitle($recordType, $record);
$recordInfo = [
'uid' => $record['uid'],
'title' => BackendUtility::cropToTitleLength($recordTitle),
'icon' => $this->iconFactory->getIconForRecord($recordType, $record, IconSize::SMALL)->getIdentifier(),
'type' => $recordType,
'typeName' => $schema->getTitle($this->getLanguageService()->sL(...)),
];
return new JsonResponse($recordInfo);
}
/**
* Get available localization handlers
*
* Returns handlers filtered by the localization context
*/
public function getHandlers(ServerRequestInterface $request): ResponseInterface
{
$params = $request->getQueryParams();
try {
$localizationInstructions = LocalizationInstructions::create($params);
} catch (\ValueError) {
return new JsonResponse(['error' => 'Invalid localization mode'], 400);
} catch (\InvalidArgumentException) {
// Validate required parameters
return new JsonResponse(null, 400);
}
// Get available handlers from registry
$handlers = $this->localizationHandlerRegistry->getAvailableHandlers($localizationInstructions);
// Prepare handlers for JSON response with translated labels
$result = [];
foreach ($handlers as $handler) {
$result[] = [
'identifier' => $handler->getIdentifier(),
'label' => $this->getLanguageService()->sL($handler->getLabel()),
'description' => $this->getLanguageService()->sL($handler->getDescription()),
'iconIdentifier' => $handler->getIconIdentifier(),
];
}
return new JsonResponse($result);
}
/**
* Get available localization modes
*/
public function getModes(ServerRequestInterface $request): ResponseInterface
{
$params = $request->getQueryParams();
if (!isset($params['recordType'], $params['recordUid'], $params['targetLanguage'])) {
return new JsonResponse(null, 400);
}
$recordType = $params['recordType'];
$recordUid = (int)$params['recordUid'];
$targetLanguage = (int)$params['targetLanguage'];
// For pages, use the recordUid directly as the page
// For other record types, find the parent page
if ($recordType === 'pages') {
$page = $recordUid;
} else {
// Get the record to find its parent page
$record = BackendUtility::getRecord($recordType, $recordUid);
if (!$record) {
return new JsonResponse(null, 404);
}
$page = (int)$record['pid'];
}
// Get page record for permission checks
$pageRecord = BackendUtility::readPageAccess($page, $this->getBackendUser()->getPagePermsClause(Permission::PAGE_SHOW));
if (!$pageRecord) {
return new JsonResponse(null, 403);
}
// Get available modes based on PageTSconfig
$pageTsConfig = BackendUtility::getPagesTSconfig($page);
$schema = $this->schemaFactory->get($recordType);
if (!$schema->hasCapability(TcaSchemaCapability::Language)) {
// Table is not language-aware
return new JsonResponse(null, 400);
}
$languageCapability = $schema->getCapability(TcaSchemaCapability::Language);
$availableModes = array_filter(
LocalizationMode::cases(),
static function (LocalizationMode $mode) use ($pageTsConfig, $languageCapability): bool {
return match ($mode) {
LocalizationMode::COPY => ($pageTsConfig['mod.']['web_layout.']['localization.']['enableCopy'] ?? true) && $languageCapability->hasTranslationSourceField(),
LocalizationMode::TRANSLATE => (bool)($pageTsConfig['mod.']['web_layout.']['localization.']['enableTranslate'] ?? true),
};
}
);
// Check if there are existing translations in the target language
// If so, we need to ensure we don't mix localization modes
// This check is only relevant for pages and tt_content records
if ($recordType === 'pages' || $recordType === 'tt_content') {
$existingMode = $this->detectExistingLocalizationMode($page, $targetLanguage);
if ($existingMode !== null) {
// Filter to only allow the existing mode
$availableModes = array_filter(
$availableModes,
static fn(LocalizationMode $mode): bool => $mode === $existingMode
);
}
}
// Sort by priority (highest first)
usort($availableModes, static fn(LocalizationMode $a, LocalizationMode $b): int => $b->getPriority() <=> $a->getPriority());
$modes = array_map(
fn(LocalizationMode $mode): array => [
'key' => $mode->value,
'label' => $this->getLanguageService()->sL($mode->getLabel()),
'description' => $this->getLanguageService()->sL($mode->getDescription()),
'iconIdentifier' => $mode->getIconIdentifier(),
],
$availableModes
);
return new JsonResponse($modes);
}
/**
* Get all target languages available for translation (excluding default language)
*/
public function getTargets(ServerRequestInterface $request): ResponseInterface
{
$params = $request->getQueryParams();
if (!isset($params['recordType'], $params['recordUid'])) {
return new JsonResponse(null, 400);
}
$recordType = $params['recordType'];
$recordUid = (int)$params['recordUid'];
// For pages, use the recordUid directly as the page
// For other record types, find the parent page
if ($recordType === 'pages') {
$page = $recordUid;
} else {
// Get the record to find its parent page
$record = BackendUtility::getRecord($recordType, $recordUid);
if (!$record) {
return new JsonResponse(null, 404);
}
$page = (int)$record['pid'];
}
// Get page record for permission checks
$pageRecord = BackendUtility::readPageAccess($page, $this->getBackendUser()->getPagePermsClause(Permission::PAGE_SHOW));
if (!$pageRecord) {
return new JsonResponse(null, 403);
}
$systemLanguages = $this->translationConfigurationProvider->getSystemLanguages($page);
$availableLanguages = [];
foreach ($systemLanguages as $languageUid => $language) {
// Exclude "All languages" (-1) and default language (0) for target language selection
if ($languageUid !== -1 && $languageUid !== 0) {
$availableLanguages[] = $language;
}
}
return new JsonResponse($availableLanguages);
}
/**
* Get source languages that have content and can be used as translation base
*/
public function getSources(ServerRequestInterface $request): ResponseInterface
{
$params = $request->getQueryParams();
if (!isset($params['recordType'], $params['recordUid'], $params['targetLanguage'])) {
return new JsonResponse(null, 400);
}
$recordType = (string)$params['recordType'];
$recordUid = (int)$params['recordUid'];
$targetLanguage = (int)$params['targetLanguage'];
// For pages, use the recordUid directly as the page
// For other record types, find the parent page
if ($recordType === 'pages') {
$page = $recordUid;
} else {
// Get the record to find its parent page
$record = BackendUtility::getRecord($recordType, $recordUid);
if (!$record) {
return new JsonResponse(null, 404);
}
$page = (int)$record['pid'];
}
// Get page record for permission checks
$pageRecord = BackendUtility::readPageAccess($page, $this->getBackendUser()->getPagePermsClause(Permission::PAGE_SHOW));
if (!$pageRecord) {
return new JsonResponse(null, 403);
}
$systemLanguages = $this->translationConfigurationProvider->getSystemLanguages($page);
$availableLanguages = [];
// Find all existing translations of the record
$record = BackendUtility::getRecord($recordType, $recordUid);
if ($record) {
$existingLanguageUids = [0]; // Always include default language
// Check each system language to see if a translation exists
foreach (array_keys($systemLanguages) as $languageUid) {
if ($languageUid > 0) { // Skip default language (0) and "All languages" (-1)
$translation = $this->localizationRepository->getRecordTranslation($recordType, $record, (int)$languageUid);
if ($translation !== null) {
$existingLanguageUids[] = $languageUid;
}
}
}
foreach ($existingLanguageUids as $languageUid) {
if ($languageUid !== $targetLanguage && isset($systemLanguages[$languageUid])) {
$availableLanguages[] = $systemLanguages[$languageUid];
}
}
// For pages with existing translations in the target language, we need to restrict source languages
// to prevent mixed translation origins (e.g., some content from language A, some from language B)
if ($recordType === 'pages') {
$availableLanguages = $this->filterSourceLanguagesForPage($recordUid, $targetLanguage, $availableLanguages);
}
}
// Language "All" should not appear as a source of translations (see bug 92757) and keys should be sequential
$availableLanguages = array_values(
array_filter($availableLanguages, static function (array $languageRecord): bool {
return (int)$languageRecord['uid'] !== -1;
})
);
return new JsonResponse($availableLanguages);
}
/**
* Get page layout and records for localization
*/
public function getContent(ServerRequestInterface $request): ResponseInterface
{
$params = $request->getQueryParams();
if (!isset($params['pageUid'], $params['targetLanguage'], $params['sourceLanguage'])) {
return new JsonResponse(null, 400);
}
$pageUid = (int)$params['pageUid'];
$targetLanguage = (int)$params['targetLanguage'];
$sourceLanguage = (int)$params['sourceLanguage'];
$records = [];
$result = $this->localizationRepository->getRecordsToCopyDatabaseResult(
$pageUid,
$targetLanguage,
$sourceLanguage,
$this->getBackendUser()->workspace
);
$flatRecords = [];
while ($row = $result->fetchAssociative()) {
BackendUtility::workspaceOL('tt_content', $row, $this->getBackendUser()->workspace, true);
if (!$row || VersionState::tryFrom($row['t3ver_state'] ?? 0) === VersionState::DELETE_PLACEHOLDER) {
continue;
}
$colPos = $row['colPos'];
if (!$this->backendLayoutView->isCTypeAllowedInColPosByPage($row['CType'], $colPos, $pageUid)) {
continue;
}
if (!isset($records[$colPos])) {
$records[$colPos] = [];
}
$recordTitle = BackendUtility::getRecordTitle('tt_content', $row);
$records[$colPos][] = [
'icon' => $this->iconFactory->getIconForRecord('tt_content', $row, IconSize::SMALL)->getIdentifier(),
'title' => BackendUtility::cropToTitleLength($recordTitle),
'uid' => $row['uid'],
];
$flatRecords[] = $row;
}
$columns = $this->getPageColumns($pageUid, $flatRecords, $params);
$event = new AfterRecordSummaryForLocalizationEvent($records, $columns);
$this->eventDispatcher->dispatch($event);
// Get the backend layout structure for visual representation
$backendLayout = $this->backendLayoutView->getBackendLayoutForPage($pageUid);
$layoutStructure = $this->buildLayoutStructure($backendLayout, $event->getColumns(), $event->getRecords());
return new JsonResponse([
'layout' => $layoutStructure,
]);
}
public function localize(ServerRequestInterface $request): ResponseInterface
{
$params = $request->getParsedBody();
if (!isset(
$params['recordType'],
$params['recordUid'],
$params['data']['sourceLanguage'],
$params['data']['targetLanguage'],
$params['data']['localizationMode']
)) {
return new JsonResponse(null, 400);
}
$recordType = $params['recordType'];
$recordUid = (int)$params['recordUid'];
$sourceLanguage = (int)$params['data']['sourceLanguage'];
$targetLanguage = (int)$params['data']['targetLanguage'];
$modeIdentifier = $params['data']['localizationMode'];
$handlerIdentifier = $params['data']['localizationHandler'] ?? 'manual';
// Prepare Additional Data
$additionalData = $params['data'];
unset($additionalData['sourceLanguage']);
unset($additionalData['targetLanguage']);
unset($additionalData['localizationMode']);
unset($additionalData['localizationHandler']);
// Validate that the mode exists
$mode = LocalizationMode::tryFrom($modeIdentifier);
if ($mode === null) {
$response = new Response('php://temp', 400, ['Content-Type' => 'application/json; charset=utf-8']);
$response->getBody()->write('Invalid localization mode "' . $modeIdentifier . '" called.');
return $response;
}
try {
$localizationInstructions = new LocalizationInstructions(
$recordType,
$recordUid,
$sourceLanguage,
$targetLanguage,
$mode,
$additionalData
);
} catch (\ValueError) {
return new JsonResponse(['error' => 'Invalid localization mode'], 400);
} catch (\InvalidArgumentException) {
// Validate required parameters
return new JsonResponse(null, 400);
}
// Process the localization using the handler
try {
// Get the handler from the registry or fall back to the default handler
if ($this->localizationHandlerRegistry->hasHandler($handlerIdentifier)) {
$handler = $this->localizationHandlerRegistry->getHandler($handlerIdentifier);
} else {
$handler = $this->localizationHandler;
}
// Use the handler to process the localization with the selected mode
$result = $handler->processLocalization($localizationInstructions);
} catch (\Exception $e) {
$result = LocalizationResult::error([$e->getMessage()]);
}
return new JsonResponse($result->jsonSerialize());
}
private function getPageColumns(int $page, array $flatRecords, array $params): array
{
$columns = [];
$backendLayout = $this->backendLayoutView->getBackendLayoutForPage($page);
foreach ($backendLayout->getUsedColumns() as $columnPos => $columnLabel) {
$columns[$columnPos] = $this->getLanguageService()->sL($columnLabel);
}
$event = new AfterPageColumnsSelectedForLocalizationEvent($columns, [], $backendLayout, $flatRecords, $params);
$this->eventDispatcher->dispatch($event);
return $event->getColumns();
}
private function buildLayoutStructure($backendLayout, array $columns, array $records): array
{
// Calculate total elements across all columns
$elementCount = 0;
foreach ($records as $colPos => $columnRecords) {
if (is_array($columnRecords)) {
$elementCount += count($columnRecords);
}
}
if (!$backendLayout) {
// Create a simple single-row layout when no backend layout is available
$layoutColumns = [];
foreach ($columns as $colPos => $columnLabel) {
$layoutColumns[] = [
'position' => (int)$colPos,
'label' => $columnLabel,
'records' => $records[$colPos] ?? [],
'colspan' => 1,
'rowspan' => 1,
'identifier' => null,
];
}
return [
'type' => 'layout',
'title' => 'Default Layout',
'identifier' => 'default',
'colCount' => count($columns),
'rowCount' => 1,
'elementCount' => $elementCount,
'rows' => [
[
'columns' => $layoutColumns,
],
],
];
}
$structure = $backendLayout->getStructure();
$layoutRows = [];
if (!empty($structure['__config']['backend_layout.']['rows.'])) {
$rows = $structure['__config']['backend_layout.']['rows.'];
ksort($rows);
foreach ($rows as $row) {
$layoutColumns = [];
if (!empty($row['columns.'])) {
foreach ($row['columns.'] as $column) {
if (!isset($column['colPos'])) {
continue;
}
$colPos = (int)$column['colPos'];
$layoutColumns[] = [
'position' => $colPos,
'label' => $columns[$colPos] ?? $column['name'],
'records' => $records[$colPos] ?? [],
'colspan' => (int)($column['colspan'] ?? 1),
'rowspan' => (int)($column['rowspan'] ?? 1),
'identifier' => $column['identifier'] ?? null,
];
}
}
$layoutRows[] = [
'columns' => $layoutColumns,
];
}
}
return [
'type' => 'layout',
'title' => $backendLayout->getTitle(),
'identifier' => $backendLayout->getIdentifier(),
'colCount' => $backendLayout->getColCount(),
'rowCount' => $backendLayout->getRowCount(),
'elementCount' => $elementCount,
'rows' => $layoutRows,
];
}
/**
* Filter available source languages for page translations based on existing content
*
* For pages, when translations already exist in the target language with content assigned,
* we need to ensure that new content is only translated from the same source language(s)
* as the existing content to avoid creating mixed translations in terms of language origin.
*
* @param int $pageUid The page UID being translated
* @param int $targetLanguage The target language ID
* @param array $availableLanguages All available source languages (to be filtered)
* @return array Filtered available language configurations
*/
private function filterSourceLanguagesForPage(int $pageUid, int $targetLanguage, array $availableLanguages): array
{
// Check if a page translation exists in the target language
$pageTranslation = $this->localizationRepository->getPageTranslations($pageUid, [$targetLanguage], $this->getBackendUser()->workspace);
if ($pageTranslation === []) {
return $availableLanguages;
}
// Get source languages used by existing content in the target language
// Note: Content elements are stored on the original page with sys_language_uid set to the target language
$usedSourceLanguages = $this->getUsedSourceLanguagesForPage($pageUid, $targetLanguage);
if (empty($usedSourceLanguages)) {
return $availableLanguages;
}
// Filter to only allow source languages already in use
return array_filter(
$availableLanguages,
static fn(array $language): bool => isset($usedSourceLanguages[(int)$language['uid']])
);
}
/**
* Get the source languages used by existing content on a page
*
* @param int $pageUid The page UID
* @param int $targetLanguage The target language ID
* @return array<int, true> Map of source language UIDs that are in use
*/
private function getUsedSourceLanguagesForPage(int $pageUid, int $targetLanguage): array
{
$schema = $this->schemaFactory->get('tt_content');
$languageCapability = $schema->getCapability(TcaSchemaCapability::Language);
if (!$languageCapability->hasTranslationSourceField()) {
return [];
}
$languageField = $languageCapability->getLanguageField()->getName();
$translationSourceField = $languageCapability->getTranslationSourceField()->getName();
// Get all l10n_source UIDs from translated content
$queryBuilder = $this->connectionPool
->getQueryBuilderForTable('tt_content');
$queryBuilder->getRestrictions()
->removeAll()
->add(GeneralUtility::makeInstance(DeletedRestriction::class))
->add(GeneralUtility::makeInstance(WorkspaceRestriction::class, $this->getBackendUser()->workspace));
$result = $queryBuilder
->select($translationSourceField)
->from('tt_content')
->where(
$queryBuilder->expr()->eq(
'pid',
$queryBuilder->createNamedParameter($pageUid, Connection::PARAM_INT)
),
$queryBuilder->expr()->eq(
$languageField,
$queryBuilder->createNamedParameter($targetLanguage, Connection::PARAM_INT)
),
$queryBuilder->expr()->gt(
$translationSourceField,
$queryBuilder->createNamedParameter(0, Connection::PARAM_INT)
)
)
->executeQuery();
// Collect unique source UIDs
$sourceUids = [];
while ($row = $result->fetchAssociative()) {
$sourceUid = (int)$row[$translationSourceField];
$sourceUids[$sourceUid] = $sourceUid;
}
if (empty($sourceUids)) {
return [];
}
// Get the language of all source records
$sourceQueryBuilder = $this->connectionPool
->getQueryBuilderForTable('tt_content');
$sourceQueryBuilder->getRestrictions()
->removeAll()
->add(GeneralUtility::makeInstance(DeletedRestriction::class));
$sourceResult = $sourceQueryBuilder
->select($languageField)
->from('tt_content')
->where(
$sourceQueryBuilder->expr()->in(
'uid',
$sourceQueryBuilder->createNamedParameter($sourceUids, Connection::PARAM_INT_ARRAY)
)
)
->groupBy($languageField)
->executeQuery();
$usedSourceLanguages = [];
while ($row = $sourceResult->fetchAssociative()) {
$sourceLanguageUid = (int)$row[$languageField];
$usedSourceLanguages[$sourceLanguageUid] = true;
}
return $usedSourceLanguages;
}
/**
* Detect the localization mode used by existing translations on a page
*
* This method checks if there are existing content elements in the target language
* and determines whether they were created using COPY (free) or TRANSLATE (connected) mode.
* This prevents mixing different localization modes on the same page, which would lead to
* inconsistent translation workflows.
*
* Note: Pages themselves are always created in connected mode (using 'localize' command),
* so we check the content elements on the page to determine the actual localization mode.
*
* The distinction is made by checking the translation origin pointer field obtained from the
* schema's language capability:
* - TRANSLATE mode (connected): Records have translation origin pointer > 0 (linked to source language)
* - COPY mode (free): Records have translation origin pointer = 0 (independent copies)
*
* @param int $pageId The page ID to check
* @param int $targetLanguage The target language ID
* @return LocalizationMode|null The detected mode, or null if no translations exist
*/
private function detectExistingLocalizationMode(int $pageId, int $targetLanguage): ?LocalizationMode
{
// Get the TCA schema to determine the correct field names
$schema = $this->schemaFactory->get('tt_content');
if (!$schema->hasCapability(TcaSchemaCapability::Language)) {
// Table is not language-aware
return null;
}
$languageCapability = $schema->getCapability(TcaSchemaCapability::Language);
$transOrigPointerField = $languageCapability->getTranslationOriginPointerField()->getName();
$languageField = $languageCapability->getLanguageField()->getName();
// Check content elements on the page, as pages themselves are always connected
// but their content determines the actual localization mode being used
$queryBuilder = $this->connectionPool
->getQueryBuilderForTable('tt_content');
$queryBuilder->getRestrictions()
->removeAll()
->add(GeneralUtility::makeInstance(DeletedRestriction::class))
->add(GeneralUtility::makeInstance(WorkspaceRestriction::class, $this->getBackendUser()->workspace));
// Select only the translation pointer field to determine the mode
$result = $queryBuilder
->select($transOrigPointerField)
->from('tt_content')
->where(
$queryBuilder->expr()->eq(
'pid',
$queryBuilder->createNamedParameter($pageId, Connection::PARAM_INT)
),
$queryBuilder->expr()->eq(
$languageField,
$queryBuilder->createNamedParameter($targetLanguage, Connection::PARAM_INT)
)
)
->executeQuery();
$hasRecords = false;
$hasConnected = false;
// Iterate through results to detect the mode
while ($row = $result->fetchAssociative()) {
$hasRecords = true;
$transOrigPointer = (int)($row[$transOrigPointerField] ?? 0);
// If we find any connected record (transOrigPointer > 0), return TRANSLATE immediately
// This prevents mixing modes even if there are also free mode records
if ($transOrigPointer > 0) {
$hasConnected = true;
break;
}
}
// No records found
if (!$hasRecords) {
return null;
}
// If any connected record exists, return TRANSLATE mode
// Otherwise, all records are free mode, return COPY
return $hasConnected ? LocalizationMode::TRANSLATE : LocalizationMode::COPY;
}
private function getBackendUser(): BackendUserAuthentication
{
return $GLOBALS['BE_USER'];
}
private function getLanguageService(): LanguageService
{
return $GLOBALS['LANG'];
}
}
@@ -0,0 +1,164 @@
<?php
declare(strict_types=1);
/*
* 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 TYPO3\CMS\Backend\Controller\Wizard;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Backend\Attribute\AsController;
use TYPO3\CMS\Backend\Form\FormDataCompiler;
use TYPO3\CMS\Backend\Form\FormDataGroup\OnTheFly;
use TYPO3\CMS\Backend\Form\FormDataProvider\DatabaseEffectivePid;
use TYPO3\CMS\Backend\Form\FormDataProvider\DatabaseParentPageRow;
use TYPO3\CMS\Backend\Form\FormDataProvider\DatabaseRowInitializeNew;
use TYPO3\CMS\Backend\Form\FormDataProvider\DatabaseUniqueUidNewRow;
use TYPO3\CMS\Backend\Form\FormDataProvider\DatabaseUserPermissionCheck;
use TYPO3\CMS\Backend\Form\FormDataProvider\InitializeProcessedTca;
use TYPO3\CMS\Backend\Form\FormDataProvider\PageTsConfig;
use TYPO3\CMS\Backend\Form\FormDataProvider\TcaSelectItems;
use TYPO3\CMS\Backend\Form\FormDataProvider\UserTsConfig;
use TYPO3\CMS\Backend\Utility\BackendUtility;
use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
use TYPO3\CMS\Core\Http\JsonResponse;
use TYPO3\CMS\Core\Imaging\IconFactory;
use TYPO3\CMS\Core\Imaging\IconSize;
use TYPO3\CMS\Core\Type\Bitmask\Permission;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Controller providing AJAX endpoints for page wizard functionality.
* Handles fetching doktypes, page details, and processed field values
*
* @internal This class is a specific Backend controller implementation and is not considered part of the Public TYPO3 API.
*/
#[AsController]
final readonly class PageWizardController
{
public function __construct(
private IconFactory $iconFactory,
private FormDataCompiler $formDataCompiler
) {}
public function getDoktypesAction(ServerRequestInterface $request): ResponseInterface
{
$position = $request->getQueryParams()['data']['position'] ?? [];
$pageUid = (int)($position['pageUid'] ?? 0);
$insertPosition = $position['insertPosition'] ?? 'inside';
$parentPageUid = $insertPosition === 'inside'
? $pageUid
: (BackendUtility::getRecord('pages', $pageUid, 'pid')['pid'] ?? null);
$backendUser = $this->getBackendUser();
$parentPage = BackendUtility::readPageAccess((int)$parentPageUid, $backendUser->getPagePermsClause(Permission::PAGE_NEW));
if (!$parentPage) {
return new JsonResponse(null, 403);
}
$formDataGroup = GeneralUtility::makeInstance(OnTheFly::class);
$formDataGroup->setProviderList([
InitializeProcessedTca::class,
DatabaseParentPageRow::class,
DatabaseUserPermissionCheck::class,
DatabaseEffectivePid::class,
UserTsConfig::class,
PageTsConfig::class,
DatabaseRowInitializeNew::class,
DatabaseUniqueUidNewRow::class,
TcaSelectItems::class,
]);
$doktypes = $this->formDataCompiler
->compile(
[
'command' => 'new',
'request' => $request,
'tableName' => 'pages',
'vanillaUid' => $parentPageUid,
],
$formDataGroup
)['processedTca']['columns']['doktype']['config']['items'] ?? [];
$result = [];
foreach ($doktypes as $doktype) {
$result[] = [
'value' => $doktype['value'] ?? '',
'label' => $doktype['label'] ?? '',
'icon' => $doktype['icon'] ?? '',
'description' => $doktype['description'] ?? '',
];
}
return new JsonResponse($result, 200);
}
public function getPageDetailAction(ServerRequestInterface $request): ResponseInterface
{
$params = $request->getQueryParams();
$pageUid = $params['pageUid'] ?? null;
if ($pageUid === null) {
return new JsonResponse(['error' => 'Missing required query parameter: pageUid'], 400);
}
if ((int)$pageUid === 0) {
return new JsonResponse([
'uid' => 0,
'title' => $GLOBALS['TYPO3_CONF_VARS']['SYS']['sitename'] ?? 'TYPO3',
'icon' => 'apps-pagetree-root',
]);
}
$page = BackendUtility::readPageAccess((int)$pageUid, $this->getBackendUser()->getPagePermsClause(Permission::PAGE_SHOW));
if (!$page) {
return new JsonResponse(null, 403);
}
$recordInfo = [
'uid' => $page['uid'],
'title' => $page['title'],
'icon' => $this->iconFactory->getIconForRecord('pages', $page, IconSize::SMALL)->getIdentifier(),
];
return new JsonResponse($recordInfo);
}
public function getProcessedValueAction(ServerRequestInterface $request): ResponseInterface
{
$params = $request->getQueryParams();
$fields = $params['fields'] ?? [];
$pageUid = (int)($params['pageUid'] ?? 0);
$page = BackendUtility::readPageAccess($pageUid, $this->getBackendUser()->getPagePermsClause(Permission::PAGE_SHOW));
if (!$page) {
return new JsonResponse(null, 403);
}
$result = [];
foreach ($fields as $fieldName => $value) {
$result[$fieldName] = BackendUtility::getProcessedValue('pages', $fieldName, $value, 0, false, false, 0, true, $pageUid);
}
return new JsonResponse($result);
}
private function getBackendUser(): BackendUserAuthentication
{
return $GLOBALS['BE_USER'];
}
}
@@ -0,0 +1,327 @@
<?php
declare(strict_types=1);
/*
* 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 TYPO3\CMS\Backend\Controller\Wizard;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Backend\Attribute\AsController;
use TYPO3\CMS\Backend\Form\Wizard\SuggestWizardDefaultReceiver;
use TYPO3\CMS\Backend\Utility\BackendUtility;
use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
use TYPO3\CMS\Core\Configuration\FlexForm\FlexFormTools;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Database\Query\QueryHelper;
use TYPO3\CMS\Core\Http\JsonResponse;
use TYPO3\CMS\Core\Schema\Capability\RootLevelCapability;
use TYPO3\CMS\Core\Schema\Capability\TcaSchemaCapability;
use TYPO3\CMS\Core\Schema\TcaSchema;
use TYPO3\CMS\Core\Schema\TcaSchemaFactory;
use TYPO3\CMS\Core\Security\RawValue;
use TYPO3\CMS\Core\Utility\ArrayUtility;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Receives ajax request from FormEngine suggest wizard and creates suggest answer as json result
* @internal This class is a specific Backend controller implementation and is not considered part of the Public TYPO3 API.
*/
#[AsController]
readonly class SuggestWizardController
{
public function __construct(
private FlexFormTools $flexFormTools,
private TcaSchemaFactory $tcaSchemaFactory,
private ConnectionPool $connectionPool,
) {}
/**
* Ajax handler for the "suggest" feature in FormEngine.
*
* @throws \RuntimeException for incomplete or invalid arguments
*/
public function searchAction(ServerRequestInterface $request): ResponseInterface
{
$parsedBody = $request->getParsedBody();
$search = $parsedBody['value'] ?? null;
$tableName = $parsedBody['tableName'] ?? null;
$fieldName = $parsedBody['fieldName'] ?? null;
$uid = $parsedBody['uid'] ?? null;
$pid = isset($parsedBody['pid']) ? (int)$parsedBody['pid'] : 0;
$dataStructureIdentifier = $parsedBody['dataStructureIdentifier'] ?? '';
$flexFormSheetName = $parsedBody['flexFormSheetName'] ?? null;
$flexFormFieldName = $parsedBody['flexFormFieldName'] ?? null;
$flexFormContainerName = $parsedBody['flexFormContainerName'] ?? null;
$flexFormContainerFieldName = $parsedBody['flexFormContainerFieldName'] ?? null;
$recordType = (string)($parsedBody['recordTypeValue'] ?? '') ?: null;
$schema = $this->tcaSchemaFactory->get($tableName);
// Determine TCA config of field
if (empty($dataStructureIdentifier)) {
// Normal columns field
$fieldInformation = $schema->getField($fieldName);
$fieldConfig = $fieldInformation->getConfiguration();
$fieldNameInPageTsConfig = $fieldName;
// With possible columnsOverrides
// @todo Validate if we can move this fallback recordType determination, should be do-able in v13?!
if ($recordType === null) {
$recordType = BackendUtility::getTCAtypeValue(
$tableName,
BackendUtility::getRecord($tableName, $uid) ?? [],
true
);
}
if ($recordType !== null && $schema->hasSubSchema($recordType)) {
$fieldConfig = $schema->getSubSchema($recordType)->getField($fieldName)->getConfiguration();
}
} else {
// A flex-form field
$dataStructure = $this->flexFormTools->parseDataStructureByIdentifier($dataStructureIdentifier, $schema);
if (empty($flexFormContainerFieldName)) {
// @todo: See if a path in pageTsConfig like "TCEForm.tableName.theContainerFieldName =" is useful and works with other pageTs, too.
$fieldNameInPageTsConfig = $flexFormFieldName;
if (!isset($dataStructure['sheets'][$flexFormSheetName]['ROOT']
['el'][$flexFormFieldName]['config'])
) {
throw new \RuntimeException(
'Specified path ' . $flexFormFieldName . ' not found in flex form data structure',
1480609491
);
}
$fieldConfig = $dataStructure['sheets'][$flexFormSheetName]['ROOT']
['el'][$flexFormFieldName]['config'];
} else {
$fieldNameInPageTsConfig = $flexFormContainerFieldName;
if (!isset($dataStructure['sheets'][$flexFormSheetName]['ROOT']
['el'][$flexFormFieldName]
['el'][$flexFormContainerName]
['el'][$flexFormContainerFieldName]['config'])
) {
throw new \RuntimeException(
'Specified path ' . $flexFormContainerName . ' not found in flex form section container data structure',
1480611208
);
}
$fieldConfig = $dataStructure['sheets'][$flexFormSheetName]['ROOT']
['el'][$flexFormFieldName]
['el'][$flexFormContainerName]
['el'][$flexFormContainerFieldName]['config'];
}
}
$pageTsConfig = BackendUtility::getPagesTSconfig($pid);
$wizardConfig = $fieldConfig['suggestOptions'] ?? [];
$queryTables = $this->getTablesToQueryFromFieldConfiguration($fieldConfig);
$whereClause = $this->getWhereClause($fieldConfig);
$resultRows = [];
// fetch the records for each query table. A query table is a table from which records are allowed to
// be added to the TCEForm selector, originally fetched from the "allowed" config option in the TCA
foreach ($queryTables as $queryTable) {
// if the table does not exist, skip it
if (!$this->tcaSchemaFactory->has($queryTable)) {
continue;
}
$config = $this->getConfigurationForTable($queryTable, $wizardConfig, $pageTsConfig, $tableName, $fieldNameInPageTsConfig);
// process addWhere
if (!isset($config['addWhere']) && $whereClause) {
$config['addWhere'] = $whereClause;
}
if (isset($config['addWhere'])) {
$replacement = [
'###THIS_UID###' => (int)$uid,
'###CURRENT_PID###' => (int)$pid,
];
if (isset($pageTsConfig['TCEFORM.'][$tableName . '.'][$fieldNameInPageTsConfig . '.'])) {
$fieldTSconfig = $pageTsConfig['TCEFORM.'][$tableName . '.'][$fieldNameInPageTsConfig . '.'];
if (isset($fieldTSconfig['PAGE_TSCONFIG_ID'])) {
$replacement['###PAGE_TSCONFIG_ID###'] = (int)$fieldTSconfig['PAGE_TSCONFIG_ID'];
}
if (isset($fieldTSconfig['PAGE_TSCONFIG_IDLIST'])) {
$replacement['###PAGE_TSCONFIG_IDLIST###'] = implode(',', GeneralUtility::intExplode(',', (string)$fieldTSconfig['PAGE_TSCONFIG_IDLIST']));
}
if (isset($fieldTSconfig['PAGE_TSCONFIG_STR'])) {
$connection = $this->connectionPool->getConnectionForTable($fieldConfig['foreign_table']);
// nasty hack, but it's currently not possible to just quote anything "inside" the value but not escaping
// the whole field as it is not known where it is used in the WHERE clause
$replacement['###PAGE_TSCONFIG_STR###'] = trim($connection->quote($fieldTSconfig['PAGE_TSCONFIG_STR']), '\'');
}
}
$config['addWhere'] = QueryHelper::quoteDatabaseIdentifiers($this->connectionPool->getConnectionForTable($queryTable), strtr(' ' . $config['addWhere'], $replacement));
}
// instantiate the class that should fetch the records for this $queryTable
$receiverClassName = $config['receiverClass'] ?? '';
if (!class_exists($receiverClassName)) {
$receiverClassName = SuggestWizardDefaultReceiver::class;
}
$receiverObj = GeneralUtility::makeInstance($receiverClassName, $queryTable, $config);
$params = [
'value' => $search,
'uid' => $uid,
];
$rows = $receiverObj->queryTable($params);
if (empty($rows)) {
continue;
}
$resultRows = $rows + $resultRows;
unset($rows);
}
// Limit the number of items in the result list
$maxItems = (int)($config['maxItemsInResultList'] ?? 10);
$maxItems = min(count($resultRows), $maxItems);
array_splice($resultRows, $maxItems);
return new JsonResponse(array_values($resultRows));
}
/**
* Checks if the current backend user is allowed to access the given table, based on the schema capabilities.
*/
protected function currentBackendUserMayAccessTable(TcaSchema $schema): bool
{
if ($this->getBackendUser()->isAdmin()) {
return true;
}
// If the user is no admin, they may not access admin-only tables
if ($schema->hasCapability(TcaSchemaCapability::AccessAdminOnly)) {
return false;
}
/** @var RootLevelCapability $rootLevelCapability */
$rootLevelCapability = $schema->getCapability(TcaSchemaCapability::RestrictionRootLevel);
// allow access to root level pages if security restrictions should be bypassed
return $rootLevelCapability->canAccessRecordsOnRootLevel();
}
/**
* Returns the configuration for the suggest wizard for the given table. This does multiple overlays from the
* TSconfig.
*
* @param string $queryTable The table to query
* @param array $wizardConfig The configuration for the wizard as configured in the data structure
* @param array $TSconfig The TSconfig array of the current page
* @param string $table The table where the wizard is used
* @param string $field The field where the wizard is used
*/
protected function getConfigurationForTable(string $queryTable, array $wizardConfig, array $TSconfig, string $table, string $field): array
{
$config = (array)($wizardConfig['default'] ?? []);
if (is_array($wizardConfig[$queryTable] ?? null)) {
ArrayUtility::mergeRecursiveWithOverrule($config, $wizardConfig[$queryTable]);
}
$globalSuggestTsConfig = $TSconfig['TCEFORM.']['suggest.'] ?? [];
$currentFieldSuggestTsConfig = $TSconfig['TCEFORM.'][$table . '.'][$field . '.']['suggest.'] ?? [];
// merge the configurations of different "levels" to get the working configuration for this table and
// field (i.e., go from the most general to the most special configuration)
if (is_array($globalSuggestTsConfig['default.'] ?? null)) {
ArrayUtility::mergeRecursiveWithOverrule($config, $this->substituteRawValues($globalSuggestTsConfig['default.']));
}
if (is_array($globalSuggestTsConfig[$queryTable . '.'] ?? null)) {
ArrayUtility::mergeRecursiveWithOverrule($config, $this->substituteRawValues($globalSuggestTsConfig[$queryTable . '.']));
}
// use $table instead of $queryTable here because we overlay a config
// for the input-field here, not for the queried table
if (is_array($currentFieldSuggestTsConfig['default.'] ?? null)) {
ArrayUtility::mergeRecursiveWithOverrule($config, $this->substituteRawValues($currentFieldSuggestTsConfig['default.']));
}
if (is_array($currentFieldSuggestTsConfig[$queryTable . '.'] ?? null)) {
ArrayUtility::mergeRecursiveWithOverrule($config, $this->substituteRawValues($currentFieldSuggestTsConfig[$queryTable . '.']));
}
return $config;
}
/**
* Checks the given field configuration for the tables that should be used for querying and returns them as an
* array.
*/
protected function getTablesToQueryFromFieldConfiguration(array $fieldConfig): array
{
$queryTables = [];
if (isset($fieldConfig['allowed'])) {
if ($fieldConfig['allowed'] !== '*') {
// list of allowed tables
$queryTables = GeneralUtility::trimExplode(',', $fieldConfig['allowed']);
} else {
// all tables are allowed, if the user can access them
/** @var TcaSchema $schema */
foreach ($this->tcaSchemaFactory->all() as $tableName => $schema) {
if ($schema->hasCapability(TcaSchemaCapability::HideInUi)) {
continue;
}
if ($this->currentBackendUserMayAccessTable($schema)) {
$queryTables[] = $tableName;
}
}
}
} elseif (isset($fieldConfig['foreign_table'])) {
// use the foreign table
$queryTables = [$fieldConfig['foreign_table']];
}
return $queryTables;
}
/**
* Wraps user functions in the configuration array as a `RawValue` object,
* to be asserted later when actually calling `GeneralUtility::callUserFunction`.
*/
protected function substituteRawValues(array $config): array
{
if (!empty($config['renderFunc'])) {
$config['renderFunc'] = new RawValue($config['renderFunc']);
}
return $config;
}
/**
* Returns the SQL WHERE clause to use for querying records. This is currently only relevant if a foreign_table
* is configured and should be used; it could e.g. be used to limit to a certain subset of records from the
* foreign table
*/
protected function getWhereClause(array $fieldConfig): string
{
if (!isset($fieldConfig['foreign_table'], $fieldConfig['foreign_table_where'])) {
return '';
}
// strip ORDER BY clause
return trim(preg_replace('/ORDER[[:space:]]+BY.*/i', '', $fieldConfig['foreign_table_where']));
}
protected function getBackendUser(): BackendUserAuthentication
{
return $GLOBALS['BE_USER'];
}
}
@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
/*
* 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 TYPO3\CMS\Backend\Controller\Wizard;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Backend\Attribute\AsController;
use TYPO3\CMS\Backend\Wizard\WizardProviderInterface;
use TYPO3\CMS\Backend\Wizard\WizardProviderRegistry;
use TYPO3\CMS\Core\Http\JsonResponse;
/**
* @internal This class is a specific Backend controller implementation and is not considered part of the Public TYPO3 API.
*/
#[AsController]
final readonly class WizardController
{
public function __construct(
private WizardProviderRegistry $wizardProviderRegistry
) {}
public function getConfigurationAction(ServerRequestInterface $request): ResponseInterface
{
return new JsonResponse(
$this->getProviderByRequest($request)
->getConfiguration($request)
->jsonSerialize()
);
}
public function submitDataAction(ServerRequestInterface $request): ResponseInterface
{
return new JsonResponse(
$this->getProviderByRequest($request)
->handleSubmit($request)
->jsonSerialize()
);
}
private function getProviderByRequest(ServerRequestInterface $request): WizardProviderInterface
{
return $this->wizardProviderRegistry->getProvider($request->getQueryParams()['mode'] ?? '');
}
}