TYPO3 v15 dev-main snapshot ()
This commit is contained in:
@@ -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'];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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'];
|
||||
}
|
||||
}
|
||||
@@ -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'];
|
||||
}
|
||||
}
|
||||
@@ -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'];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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'];
|
||||
}
|
||||
}
|
||||
@@ -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'];
|
||||
}
|
||||
}
|
||||
@@ -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'];
|
||||
}
|
||||
}
|
||||
@@ -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'];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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'];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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'];
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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'];
|
||||
}
|
||||
}
|
||||
@@ -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 : '';
|
||||
}
|
||||
}
|
||||
@@ -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'] ?? '');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user