diff --git a/composer.json b/composer.json index 49ee1ed1d9dcd16062b9cabbe8cb39fc620959d8..92c17eff14d2e950c29dd0a817a04c1696a42268 100644 --- a/composer.json +++ b/composer.json @@ -88,6 +88,7 @@ "symfony/property-info": "^6.2", "symfony/rate-limiter": "^6.2", "symfony/routing": "^6.2", + "symfony/uid": "^6.2", "symfony/var-dumper": "^6.2", "symfony/yaml": "^6.2", "typo3/class-alias-loader": "^1.1.4", @@ -199,6 +200,7 @@ "typo3/cms-linkvalidator": "self.version", "typo3/cms-lowlevel": "self.version", "typo3/cms-opendocs": "self.version", + "typo3/cms-reactions": "self.version", "typo3/cms-recordlist": "self.version", "typo3/cms-recycler": "self.version", "typo3/cms-redirects": "self.version", @@ -236,6 +238,7 @@ "TYPO3\\CMS\\Linkvalidator\\": "typo3/sysext/linkvalidator/Classes/", "TYPO3\\CMS\\Lowlevel\\": "typo3/sysext/lowlevel/Classes/", "TYPO3\\CMS\\Opendocs\\": "typo3/sysext/opendocs/Classes/", + "TYPO3\\CMS\\Reactions\\": "typo3/sysext/reactions/Classes/", "TYPO3\\CMS\\Recycler\\": "typo3/sysext/recycler/Classes/", "TYPO3\\CMS\\Redirects\\": "typo3/sysext/redirects/Classes/", "TYPO3\\CMS\\Reports\\": "typo3/sysext/reports/Classes/", @@ -280,6 +283,7 @@ "TYPO3\\CMS\\Linkvalidator\\Tests\\": "typo3/sysext/linkvalidator/Tests/", "TYPO3\\CMS\\Lowlevel\\Tests\\": "typo3/sysext/lowlevel/Tests/", "TYPO3\\CMS\\Opendocs\\Tests\\": "typo3/sysext/opendocs/Tests/", + "TYPO3\\CMS\\Reactions\\Tests\\": "typo3/sysext/reactions/Tests/", "TYPO3\\CMS\\Redirects\\Tests\\": "typo3/sysext/redirects/Tests/", "TYPO3\\CMS\\Reports\\Tests\\": "typo3/sysext/reports/Tests/", "TYPO3\\CMS\\RteCKEditor\\Tests\\": "typo3/sysext/rte_ckeditor/Tests/", diff --git a/composer.lock b/composer.lock index a15b897e1e81a4f25fd96a918f12fbfd5a3201fe..25ae4d0a9ccef99e757cf4cc94fe7c81a4ffd71a 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "3ea07a08189feecb07e9c13420a207b3", + "content-hash": "ab839c89c68caf9cc01d4fc45b1da848", "packages": [ { "name": "bacon/bacon-qr-code", @@ -3344,6 +3344,88 @@ ], "time": "2022-11-03T14:55:06+00:00" }, + { + "name": "symfony/polyfill-uuid", + "version": "v1.27.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-uuid.git", + "reference": "f3cf1a645c2734236ed1e2e671e273eeb3586166" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-uuid/zipball/f3cf1a645c2734236ed1e2e671e273eeb3586166", + "reference": "f3cf1a645c2734236ed1e2e671e273eeb3586166", + "shasum": "" + }, + "require": { + "php": ">=7.1" + }, + "provide": { + "ext-uuid": "*" + }, + "suggest": { + "ext-uuid": "For best performance" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.27-dev" + }, + "thanks": { + "name": "symfony/polyfill", + "url": "https://github.com/symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Uuid\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Grégoire Pineau", + "email": "lyrixx@lyrixx.info" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for uuid functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "uuid" + ], + "support": { + "source": "https://github.com/symfony/polyfill-uuid/tree/v1.27.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2022-11-03T14:55:06+00:00" + }, { "name": "symfony/property-access", "version": "v6.2.0", @@ -3845,6 +3927,80 @@ ], "time": "2022-11-30T17:13:47+00:00" }, + { + "name": "symfony/uid", + "version": "v6.2.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/uid.git", + "reference": "4f9f537e57261519808a7ce1d941490736522bbc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/uid/zipball/4f9f537e57261519808a7ce1d941490736522bbc", + "reference": "4f9f537e57261519808a7ce1d941490736522bbc", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "symfony/polyfill-uuid": "^1.15" + }, + "require-dev": { + "symfony/console": "^5.4|^6.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Uid\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Grégoire Pineau", + "email": "lyrixx@lyrixx.info" + }, + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides an object-oriented API to generate and represent UIDs", + "homepage": "https://symfony.com", + "keywords": [ + "UID", + "ulid", + "uuid" + ], + "support": { + "source": "https://github.com/symfony/uid/tree/v6.2.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2022-10-09T08:55:40+00:00" + }, { "name": "symfony/var-dumper", "version": "v6.2.0", @@ -8533,12 +8689,12 @@ "source": { "type": "git", "url": "https://github.com/TYPO3/testing-framework.git", - "reference": "1b497b899377d9412e04e00e7f87b309095cc0fb" + "reference": "59c0550367dd2c8b7f52e8561f97c786cf886b2c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/TYPO3/testing-framework/zipball/1b497b899377d9412e04e00e7f87b309095cc0fb", - "reference": "1b497b899377d9412e04e00e7f87b309095cc0fb", + "url": "https://api.github.com/repos/TYPO3/testing-framework/zipball/59c0550367dd2c8b7f52e8561f97c786cf886b2c", + "reference": "59c0550367dd2c8b7f52e8561f97c786cf886b2c", "shasum": "" }, "require": { @@ -8597,7 +8753,7 @@ "issues": "https://github.com/TYPO3/testing-framework/issues", "source": "https://github.com/TYPO3/testing-framework/tree/main" }, - "time": "2022-11-16T15:24:44+00:00" + "time": "2022-12-01T15:20:14+00:00" } ], "aliases": [], diff --git a/typo3/sysext/core/Documentation/Changelog/12.1/Feature-98373-ReactionIncomingWebHooksforTYPO3.rst b/typo3/sysext/core/Documentation/Changelog/12.1/Feature-98373-ReactionIncomingWebHooksforTYPO3.rst new file mode 100644 index 0000000000000000000000000000000000000000..0901f2e02d159dcc7a0d9c277d1da72e7014e7ec --- /dev/null +++ b/typo3/sysext/core/Documentation/Changelog/12.1/Feature-98373-ReactionIncomingWebHooksforTYPO3.rst @@ -0,0 +1,66 @@ +.. include:: /Includes.rst.txt + +.. _feature-98373-1663587471: + +========================================================= +Feature: #98373 - Reactions - Incoming WebHooks for TYPO3 +========================================================= + +See :issue:`98373` + +Description +=========== + +This feature adds the possibility to send incoming webhooks to a TYPO3 instance. + +With the new backend module, it is possible to configure the reactions triggered +by any webhook. + +A webhook is defined as an authorized POST request to the backend. + +The core provides a basic default reaction that can be used to create +records triggered and enriched by data from the caller. + +Additionally, the core provides the :php:`\TYPO3\CMS\Reactions\Reaction\ReactionInterface` +to allow extension authors to add their own reaction types. + +Any reaction record is defined by a unique uid and also requires a secret. Both +information are generated in the backend. The secret is only visible once and +stored in the database as an encrypted value like a backend user password. + +Next to static field values does the "create record" reaction feature placeholders, +which can be used to dynamically set field values, by resolving the incoming +data from the webhooks' payload. The syntax to for those values is :code:`${key}`. +The key can be a simple string or a path to a nested value like :code:`${key.nested}`. + +Definition of the placeholders in the record: +--------------------------------------------- + +.. code-block:: text + + ${title} + ${description} + ${key.nested} + +Example payload for placeholders: +--------------------------------- + +.. code-block:: json + + { + "title": "My title", + "description": "My description", + "key": { + "nested": "bar" + } + } + +Impact +====== + +This feature allows everybody to provide additional value for any TYPO3 instance. +By reacting to webhooks, TYPO3 can now be used to create records in the backend. +Furthermore, by implementing the :php:`ReactionInterface`, it is possible to +create any custom reaction. + +.. index:: Backend, ext:reactions diff --git a/typo3/sysext/reactions/.gitattributes b/typo3/sysext/reactions/.gitattributes new file mode 100644 index 0000000000000000000000000000000000000000..79ede152a8388ffb534b68d44b887ef6d9e759ab --- /dev/null +++ b/typo3/sysext/reactions/.gitattributes @@ -0,0 +1,2 @@ +/.gitattributes export-ignore +/Tests/ export-ignore diff --git a/typo3/sysext/reactions/Classes/Authentication/ReactionUserAuthentication.php b/typo3/sysext/reactions/Classes/Authentication/ReactionUserAuthentication.php new file mode 100644 index 0000000000000000000000000000000000000000..02122c1f8fb55817f3aef0e341d8f7c51a02acc3 --- /dev/null +++ b/typo3/sysext/reactions/Classes/Authentication/ReactionUserAuthentication.php @@ -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\Reactions\Authentication; + +use Psr\Http\Message\ServerRequestInterface; +use TYPO3\CMS\Core\Authentication\BackendUserAuthentication; +use TYPO3\CMS\Reactions\Model\ReactionInstruction; + +/** + * TYPO3 backend user authentication for webhooks + * Auto-logs in, only allowed in webhooks context + * + * @internal not part of TYPO3 Core API as this part is experimental + */ +class ReactionUserAuthentication extends BackendUserAuthentication +{ + public $dontSetCookie = true; + protected ?ReactionInstruction $reactionInstruction = null; + + public function setReactionInstruction(ReactionInstruction $reactionInstruction): void + { + $this->reactionInstruction = $reactionInstruction; + if ($reactionInstruction->getImpersonateUser()) { + $this->setBeUserByUid($reactionInstruction->getImpersonateUser()); + } + } + + public function start(ServerRequestInterface $request) + { + if (empty($this->user['uid'])) { + return; + } + $this->unpack_uc(); + // The groups are fetched and ready for permission checking in this initialization. + $this->fetchGroupData(); + $this->backendSetUC(); + } + + /** + * Replacement for AbstractUserAuthentication::checkAuthentication() + * + * Not required in WebHook mode if no user is impersonated, therefore empty. + */ + public function checkAuthentication(ServerRequestInterface $request) + { + // do nothing + } + + public function getOriginalUserIdWhenInSwitchUserMode(): ?int + { + return null; + } + + public function backendCheckLogin(): void + { + // do nothing + } + + /** + * Determines whether a webhook backend user is allowed to access TYPO3. + * Only when adminOnly is off (=0) + * + * @internal + */ + public function isUserAllowedToLogin(): bool + { + return (int)$GLOBALS['TYPO3_CONF_VARS']['BE']['adminOnly'] === 0; + } + + public function initializeBackendLogin(): void + { + throw new \RuntimeException('Login Error: No login possible for reaction.', 1669800914); + } +} diff --git a/typo3/sysext/reactions/Classes/Controller/ManagementController.php b/typo3/sysext/reactions/Classes/Controller/ManagementController.php new file mode 100644 index 0000000000000000000000000000000000000000..5a568782f75b9da333f5cee388b5a393d89a5a6f --- /dev/null +++ b/typo3/sysext/reactions/Classes/Controller/ManagementController.php @@ -0,0 +1,111 @@ +<?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\Reactions\Controller; + +use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\ServerRequestInterface; +use TYPO3\CMS\Backend\Attribute\Controller; +use TYPO3\CMS\Backend\Routing\UriBuilder; +use TYPO3\CMS\Backend\Template\Components\ButtonBar; +use TYPO3\CMS\Backend\Template\ModuleTemplate; +use TYPO3\CMS\Backend\Template\ModuleTemplateFactory; +use TYPO3\CMS\Core\Imaging\Icon; +use TYPO3\CMS\Core\Imaging\IconFactory; +use TYPO3\CMS\Core\Localization\LanguageService; +use TYPO3\CMS\Core\Pagination\SimplePagination; +use TYPO3\CMS\Reactions\Pagination\DemandedArrayPaginator; +use TYPO3\CMS\Reactions\ReactionRegistry; +use TYPO3\CMS\Reactions\Repository\ReactionDemand; +use TYPO3\CMS\Reactions\Repository\ReactionRepository; + +/** + * The System > Reaction module: Rendering the listing of reactions. + * + * @internal This class is a specific Backend controller implementation and is not part of the TYPO3's Core API. + */ +#[Controller] +class ManagementController +{ + public function __construct( + private readonly UriBuilder $uriBuilder, + private readonly IconFactory $iconFactory, + private readonly ModuleTemplateFactory $moduleTemplateFactory, + private readonly ReactionRegistry $reactionRegistry, + private readonly ReactionRepository $reactionRepository + ) { + } + + public function handleRequest(ServerRequestInterface $request): ResponseInterface + { + $view = $this->moduleTemplateFactory->create($request); + $demand = ReactionDemand::fromRequest($request); + + $this->registerDocHeaderButtons($view, $request->getAttribute('normalizedParams')->getRequestUri()); + + $reactionRecords = $this->reactionRepository->getReactionRecords($demand); + $paginator = new DemandedArrayPaginator($reactionRecords, $demand->getPage(), $demand->getLimit(), $this->reactionRepository->countAll()); + $pagination = new SimplePagination($paginator); + + return $view->assignMultiple([ + 'demand' => $demand, + 'reactionTypes' => $this->reactionRegistry->getAvailableReactionTypes(), + 'paginator' => $paginator, + 'pagination' => $pagination, + 'reactionRecords' => $reactionRecords, + ])->renderResponse('Management/Overview'); + } + + protected function registerDocHeaderButtons(ModuleTemplate $view, string $requestUri): void + { + $languageService = $this->getLanguageService(); + $buttonBar = $view->getDocHeaderComponent()->getButtonBar(); + + // Create new + $newRecordButton = $buttonBar->makeLinkButton() + ->setHref((string)$this->uriBuilder->buildUriFromRoute( + 'record_edit', + [ + 'edit' => ['sys_reaction' => ['new']], + 'returnUrl' => (string)$this->uriBuilder->buildUriFromRoute('system_reactions'), + ] + )) + ->setShowLabelText(true) + ->setTitle($languageService->sL('LLL:EXT:reactions/Resources/Private/Language/locallang_module_reactions.xlf:reaction_create')) + ->setIcon($this->iconFactory->getIcon('actions-add', Icon::SIZE_SMALL)); + $buttonBar->addButton($newRecordButton, ButtonBar::BUTTON_POSITION_LEFT, 10); + + // Reload + $reloadButton = $buttonBar->makeLinkButton() + ->setHref($requestUri) + ->setTitle($languageService->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.reload')) + ->setIcon($this->iconFactory->getIcon('actions-refresh', Icon::SIZE_SMALL)); + $buttonBar->addButton($reloadButton, ButtonBar::BUTTON_POSITION_RIGHT); + + // Shortcut + // @todo Demand should be respected for shortcuts + $shortcutButton = $buttonBar->makeShortcutButton() + ->setRouteIdentifier('system_reactions') + ->setDisplayName($languageService->sL('LLL:EXT:reactions/Resources/Private/Language/locallang_module_reactions.xlf:mlang_labels_tablabel')); + $buttonBar->addButton($shortcutButton, ButtonBar::BUTTON_POSITION_RIGHT); + } + + protected function getLanguageService(): LanguageService + { + return $GLOBALS['LANG']; + } +} diff --git a/typo3/sysext/reactions/Classes/Exception/ReactionNotFoundException.php b/typo3/sysext/reactions/Classes/Exception/ReactionNotFoundException.php new file mode 100644 index 0000000000000000000000000000000000000000..5ee067d8458788deace647015e5628f87328d884 --- /dev/null +++ b/typo3/sysext/reactions/Classes/Exception/ReactionNotFoundException.php @@ -0,0 +1,22 @@ +<?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\Reactions\Exception; + +class ReactionNotFoundException extends \RuntimeException +{ +} diff --git a/typo3/sysext/reactions/Classes/Form/Element/FieldMapElement.php b/typo3/sysext/reactions/Classes/Form/Element/FieldMapElement.php new file mode 100644 index 0000000000000000000000000000000000000000..6475950e22ed923c265b5f135353b85bb9154df2 --- /dev/null +++ b/typo3/sysext/reactions/Classes/Form/Element/FieldMapElement.php @@ -0,0 +1,100 @@ +<?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\Reactions\Form\Element; + +use TYPO3\CMS\Backend\Form\Element\AbstractFormElement; + +/** + * Creates a dynamic element to add values to table fields. + * + * This is rendered for config type=user, renderType=fieldMap + * + * @internal This is a specific hook implementation and is not considered part of the Public TYPO3 API. + */ +class FieldMapElement extends AbstractFormElement +{ + /** + * Default field information enabled for this element. + * + * @var array + */ + protected $defaultFieldInformation = [ + 'tcaDescription' => [ + 'renderType' => 'tcaDescription', + ], + ]; + + protected array $supportedFieldTypes = ['input', 'textarea', 'text']; + + public function render(): array + { + $languageService = $this->getLanguageService(); + $resultArray = $this->initializeResultArray(); + $parameterArray = $this->data['parameterArray']; + $itemValue = $parameterArray['itemFormElValue']; + $itemName = $parameterArray['itemFormElName']; + + $tableName = (string)($this->data['databaseRow']['table_name'][0] ?? ''); + $columns = $GLOBALS['TCA'][$tableName]['columns'] ?? []; + + $fieldsHtml = ''; + if (is_array($columns) && $columns !== []) { + foreach ($columns as $fieldName => $fieldConfig) { + if (!in_array($fieldConfig['config']['type'], $this->supportedFieldTypes, true)) { + continue; + } + $fieldName = htmlspecialchars($fieldName); + $fieldValue = is_array($itemValue) && isset($itemValue[$fieldName]) ? htmlspecialchars((string)$itemValue[$fieldName]) : ''; + $fieldsHtml .= ' + <div class="form-group"> + <label for="' . $fieldName . '"> + ' . $languageService->sL($fieldConfig['label']) /** @todo This is not how a field label should be resolved **/ . ' + </label> + <input type="text" class="form-control" id="' . $fieldName . '" name="' . htmlspecialchars($itemName) . '[' . $fieldName . ']" value="' . $fieldValue . '"> + </div>'; + } + } + + if ($fieldsHtml !== '') { + $fieldsHtml = '<div class="row">' . $fieldsHtml . '</div>'; + } else { + $fieldsHtml = ' + <div class="alert alert-warning"> + ' . htmlspecialchars(sprintf($languageService->sL('LLL:EXT:reactions/Resources/Private/Language/locallang_db.xlf:fieldMapElement.noFields'), $tableName)) . ' + </div>'; + } + + $fieldInformationResult = $this->renderFieldInformation(); + $fieldInformationHtml = $fieldInformationResult['html']; + $resultArray = $this->mergeChildReturnIntoExistingResult($resultArray, $fieldInformationResult, false); + + $html = []; + $html[] = '<div class="formengine-field-item t3js-formengine-field-item">'; + $html[] = $fieldInformationHtml; + $html[] = '<div class="form-control-wrap" style="max-width: ' . $this->formMaxWidth($this->defaultInputWidth) . 'px">'; + $html[] = '<div class="form-wizards-wrap">'; + $html[] = '<div class="form-wizards-element">'; + $html[] = $fieldsHtml; + $html[] = '</div>'; + $html[] = '</div>'; + $html[] = '</div>'; + $html[] = '</div>'; + $resultArray['html'] = implode(LF, $html); + return $resultArray; + } +} diff --git a/typo3/sysext/reactions/Classes/Form/Element/UuidElement.php b/typo3/sysext/reactions/Classes/Form/Element/UuidElement.php new file mode 100644 index 0000000000000000000000000000000000000000..f9298e839bc0a0f7e27e37b381529236e2d5ead3 --- /dev/null +++ b/typo3/sysext/reactions/Classes/Form/Element/UuidElement.php @@ -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\Reactions\Form\Element; + +use Symfony\Component\Uid\Uuid; +use TYPO3\CMS\Backend\Form\Element\AbstractFormElement; +use TYPO3\CMS\Core\Utility\GeneralUtility; +use TYPO3\CMS\Core\Utility\StringUtility; + +/** + * Creates a readonly input element with a UUID. + * + * This is rendered for config type=user, renderType=uuid + * + * @internal This is a specific hook implementation and is not considered part of the Public TYPO3 API. + */ +class UuidElement extends AbstractFormElement +{ + /** + * Default field information enabled for this element. + * + * @var array + */ + protected $defaultFieldInformation = [ + 'tcaDescription' => [ + 'renderType' => 'tcaDescription', + ], + ]; + + public function render() + { + $resultArray = $this->initializeResultArray(); + $parameterArray = $this->data['parameterArray']; + $itemValue = $parameterArray['itemFormElValue'] ?: (string)Uuid::v4(); + $fieldId = StringUtility::getUniqueId('formengine-input-'); + + $attributes = [ + 'id' => $fieldId, + 'name' => htmlspecialchars($parameterArray['itemFormElName']), + 'size' => 40, + 'class' => 'form-control', + 'data-formengine-input-name' => htmlspecialchars($parameterArray['itemFormElName']), + ]; + + $fieldInformationResult = $this->renderFieldInformation(); + $fieldInformationHtml = $fieldInformationResult['html']; + $resultArray = $this->mergeChildReturnIntoExistingResult($resultArray, $fieldInformationResult, false); + + $html = []; + $html[] = '<div class="formengine-field-item t3js-formengine-field-item">'; + $html[] = $fieldInformationHtml; + $html[] = '<div class="form-control-wrap" style="max-width: ' . $this->formMaxWidth($this->defaultInputWidth) . 'px">'; + $html[] = '<div class="form-wizards-wrap">'; + $html[] = '<div class="form-wizards-element">'; + $html[] = '<input type="text" readonly="readonly" disabled="disabled" value="' . htmlspecialchars($itemValue, ENT_QUOTES) . '" '; + $html[] = GeneralUtility::implodeAttributes($attributes, true); + $html[] = '/>'; + $html[] = '</div>'; + $html[] = '</div>'; + $html[] = '</div>'; + $html[] = '</div>'; + $resultArray['html'] = implode(LF, $html); + return $resultArray; + } +} diff --git a/typo3/sysext/reactions/Classes/Form/ReactionItemsProcFunc.php b/typo3/sysext/reactions/Classes/Form/ReactionItemsProcFunc.php new file mode 100644 index 0000000000000000000000000000000000000000..d4bf02d95a009d8c146bc7021933ccdb9211df59 --- /dev/null +++ b/typo3/sysext/reactions/Classes/Form/ReactionItemsProcFunc.php @@ -0,0 +1,52 @@ +<?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\Reactions\Form; + +use TYPO3\CMS\Core\Imaging\IconFactory; + +/** + * Helper method for TCA / FormEngine to list tables available for reactions + * + * @internal + */ +class ReactionItemsProcFunc +{ + public function __construct( + private readonly IconFactory $iconFactory + ) { + } + + public function populateAvailableContentTables(array &$fieldDefinition): void + { + foreach ($GLOBALS['TCA'] as $tableName => $tableConfiguration) { + if ($tableConfiguration['ctrl']['adminOnly'] ?? false) { + // Hide "admin only" tables + continue; + } + if (($tableConfiguration['ctrl']['groupName'] ?? '') !== 'content' && $tableName !== 'pages') { + // Hide tables that are not in the content group + continue; + } + $fieldDefinition['items'][] = [ + ($tableConfiguration['ctrl']['title'] ?? '') ?: $tableName, + $tableName, + $this->iconFactory->mapRecordTypeToIconIdentifier($tableName, []), + ]; + } + } +} diff --git a/typo3/sysext/reactions/Classes/Hooks/DataHandlerHook.php b/typo3/sysext/reactions/Classes/Hooks/DataHandlerHook.php new file mode 100644 index 0000000000000000000000000000000000000000..46abba94c4848812f5dc720d7761d51a7b2ccf08 --- /dev/null +++ b/typo3/sysext/reactions/Classes/Hooks/DataHandlerHook.php @@ -0,0 +1,47 @@ +<?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\Reactions\Hooks; + +use Symfony\Component\Uid\Uuid; +use TYPO3\CMS\Core\DataHandling\DataHandler; + +/** + * @internal This is a specific hook implementation and is not considered part of the Public TYPO3 API. + */ +class DataHandlerHook +{ + public function processDatamap_postProcessFieldArray($status, $table, $id, array &$fieldArray, DataHandler $dataHandler): void + { + // Only consider reactions + if ($table !== 'sys_reaction') { + return; + } + // Only consider new reactions + if ($status !== 'new') { + return; + } + // Create a UUID for a new reaction if non is present in the field array + if (!isset($fieldArray['identifier'])) { + $fieldArray['identifier'] = (string)Uuid::v4(); + } + // Create a valid UUID for a new reaction if given identifier is invalid + if (!Uuid::isValid($fieldArray['identifier'])) { + $fieldArray['identifier'] = (string)Uuid::v4(); + } + } +} diff --git a/typo3/sysext/reactions/Classes/Http/Middleware/ReactionResolver.php b/typo3/sysext/reactions/Classes/Http/Middleware/ReactionResolver.php new file mode 100644 index 0000000000000000000000000000000000000000..d72682d3b514150381aa6e141960dd233b31af17 --- /dev/null +++ b/typo3/sysext/reactions/Classes/Http/Middleware/ReactionResolver.php @@ -0,0 +1,108 @@ +<?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\Reactions\Http\Middleware; + +use Psr\Http\Message\ResponseFactoryInterface; +use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\ServerRequestInterface; +use Psr\Http\Message\StreamFactoryInterface; +use Psr\Http\Server\MiddlewareInterface; +use Psr\Http\Server\RequestHandlerInterface; +use Psr\Log\LoggerInterface; +use Symfony\Component\Uid\Uuid; +use TYPO3\CMS\Core\Utility\GeneralUtility; +use TYPO3\CMS\Reactions\Authentication\ReactionUserAuthentication; +use TYPO3\CMS\Reactions\Http\ReactionHandler; +use TYPO3\CMS\Reactions\Repository\ReactionRepository; + +/** + * Hooks into the backend request, and checks if a reaction is triggered, + * if so, jump directly to the ReactionHandler. + * + * @internal This is a specific Request controller implementation and is not considered part of the Public TYPO3 API. + */ +class ReactionResolver implements MiddlewareInterface +{ + public function __construct( + private readonly LoggerInterface $logger, + private readonly ReactionHandler $reactionHandler, + private readonly ReactionRepository $reactionRepository, + private readonly ResponseFactoryInterface $responseFactory, + private readonly StreamFactoryInterface $streamFactory, + ) { + } + + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface + { + // 1. We only listen to the "reaction" endpoint + $route = $request->getAttribute('route'); + if ($route->getOption('_identifier') !== 'reaction') { + return $handler->handle($request); + } + + // 2. Security check + $reactionIdentifier = $this->resolveReactionIdentifier($request); + $secretKey = $this->resolveReactionSecret($request); + if ($secretKey === '' || $reactionIdentifier === null || !Uuid::isValid($reactionIdentifier)) { + return $this->jsonResponse(['Invalid information'], 503); + } + + $reaction = $this->reactionRepository->getReactionRecordByIdentifier($reactionIdentifier); + if ($reaction === null) { + $this->logger->warning('No reaction found for given identifier', [ + 'request' => $request, + ]); + return $this->jsonResponse(['No reaction found for given identifier'], 503); + } + + if (!$reaction->isSecretValid($secretKey)) { + $this->logger->error('Invalid secret given', [ + 'request' => $request, + ]); + return $this->jsonResponse(['Invalid secret given'], 503); + } + + // 3. Handle reaction user authentication + $user = GeneralUtility::makeInstance(ReactionUserAuthentication::class); + $user->setReactionInstruction($reaction); + $user->start($request); + + // 4. Handle reaction + return $this->reactionHandler->handleReaction($request, $reaction, $user); + } + + protected function resolveReactionIdentifier(ServerRequestInterface $request): ?string + { + // @todo: this should be handled in Backend Routing in the future + [$path, $reactionId] = GeneralUtility::revExplode('/', $request->getUri()->getPath(), 2); + return $reactionId !== '' ? $reactionId : null; + } + + protected function resolveReactionSecret(ServerRequestInterface $request): string + { + return $request->getHeaderLine('x-api-key'); + } + + protected function jsonResponse(array $data, int $statusCode = 200): ResponseInterface + { + return $this->responseFactory + ->createResponse($statusCode) + ->withHeader('Content-Type', 'application/json') + ->withBody($this->streamFactory->createStream((string)json_encode($data))); + } +} diff --git a/typo3/sysext/reactions/Classes/Http/ReactionHandler.php b/typo3/sysext/reactions/Classes/Http/ReactionHandler.php new file mode 100644 index 0000000000000000000000000000000000000000..1060cc0d12b8c294b398e348d11deaae6dee647d --- /dev/null +++ b/typo3/sysext/reactions/Classes/Http/ReactionHandler.php @@ -0,0 +1,91 @@ +<?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\Reactions\Http; + +use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\ServerRequestInterface; +use Psr\Log\LoggerInterface; +use TYPO3\CMS\Core\Localization\LanguageServiceFactory; +use TYPO3\CMS\Reactions\Authentication\ReactionUserAuthentication; +use TYPO3\CMS\Reactions\Exception\ReactionNotFoundException; +use TYPO3\CMS\Reactions\Model\ReactionInstruction; +use TYPO3\CMS\Reactions\ReactionRegistry; + +/** + * Endpoint for triggering the reaction handler. + * + * Resolves the payload and calls the actual Reaction Type with the request, + * the payload, and sends the response in return. + * + * At this point, the evaluation etc. all need to have happened. + * + * @internal This is a specific controller implementation and is not considered part of the Public TYPO3 API. + */ +class ReactionHandler +{ + public function __construct( + private readonly ReactionRegistry $reactionRegistry, + private readonly LoggerInterface $logger, + private readonly LanguageServiceFactory $languageServiceFactory + ) { + } + + public function handleReaction(ServerRequestInterface $request, ?ReactionInstruction $reactionInstruction, ReactionUserAuthentication $user): ResponseInterface + { + if ($reactionInstruction === null) { + $this->logger->warning('No reaction given', [ + 'request' => $request, + ]); + throw new ReactionNotFoundException('No reaction given', 1669757255); + } + $reaction = $this->reactionRegistry->getReactionByType($reactionInstruction->getType()); + if ($reaction === null) { + throw new ReactionNotFoundException('No reaction found for given identifier', 1662458842); + } + + // Prepare the user and language object before calling the reaction execution process + $GLOBALS['LANG'] = $this->languageServiceFactory->createFromUserPreferences($user); + $request = $request->withAttribute('backend.user', $user); + + $payload = $this->getPayload($request); + $response = $reaction->react($request, $payload, $reactionInstruction); + $this->logger->info('Reaction was handled successfully', [ + 'request' => $request, + ]); + return $this->buildReactionResponse($response); + } + + protected function getPayload(ServerRequestInterface $request): array + { + $body = (string)$request->getBody(); + + try { + $payload = json_decode($body, true, 512, JSON_THROW_ON_ERROR); + return is_array($payload) ? $payload : []; + } catch (\JsonException $e) { + // do nothing + return []; + } + } + + protected function buildReactionResponse(ResponseInterface $response): ResponseInterface + { + return $response + ->withHeader('X-TYPO3-Reaction-Success', $response->getStatusCode() >= 200 && $response->getStatusCode() < 300 ? 'true' : 'false'); + } +} diff --git a/typo3/sysext/reactions/Classes/Model/ReactionInstruction.php b/typo3/sysext/reactions/Classes/Model/ReactionInstruction.php new file mode 100644 index 0000000000000000000000000000000000000000..99dd84b56bd22375de1daeba2c6d9c6831227888 --- /dev/null +++ b/typo3/sysext/reactions/Classes/Model/ReactionInstruction.php @@ -0,0 +1,68 @@ +<?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\Reactions\Model; + +use TYPO3\CMS\Core\Crypto\PasswordHashing\PasswordHashFactory; +use TYPO3\CMS\Core\Utility\GeneralUtility; + +/** + * An entity for a DB record of sys_reaction + */ +class ReactionInstruction +{ + public function __construct( + protected array $record + ) { + } + + public function getUid(): int + { + return $this->record['uid']; + } + + public function getName(): string + { + return $this->record['name']; + } + + public function getType(): string + { + return $this->record['reactiontype']; + } + + public function getIdentifier(): string + { + return $this->record['identifier']; + } + + public function getImpersonateUser(): int + { + return $this->record['impersonate_user']; + } + + public function isSecretValid(string $secret): bool + { + $hashInstance = GeneralUtility::makeInstance(PasswordHashFactory::class)->getDefaultHashInstance('BE'); + return $hashInstance->checkPassword($secret, $this->record['secret']); + } + + public function toArray(): array + { + return $this->record; + } +} diff --git a/typo3/sysext/reactions/Classes/Pagination/DemandedArrayPaginator.php b/typo3/sysext/reactions/Classes/Pagination/DemandedArrayPaginator.php new file mode 100644 index 0000000000000000000000000000000000000000..31e27af07f5a242f0af8c25c1863b0fb1f7d2b50 --- /dev/null +++ b/typo3/sysext/reactions/Classes/Pagination/DemandedArrayPaginator.php @@ -0,0 +1,66 @@ +<?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\Reactions\Pagination; + +use TYPO3\CMS\Core\Pagination\AbstractPaginator; + +/** + * @internal + * @todo should be replaced with the regular ArrayPaginator + */ +final class DemandedArrayPaginator extends AbstractPaginator +{ + private array $items; + private int $allCount; + + private array $paginatedItems = []; + + public function __construct( + array $items, + int $currentPageNumber = 1, + int $itemsPerPage = 10, + int $allCount = 0 + ) { + $this->items = $items; + $this->setCurrentPageNumber($currentPageNumber); + $this->setItemsPerPage($itemsPerPage); + $this->allCount = $allCount; + + $this->updateInternalState(); + } + + public function getPaginatedItems(): iterable + { + return $this->paginatedItems; + } + + protected function updatePaginatedItems(int $itemsPerPage, int $offset): void + { + $this->paginatedItems = $this->items; + } + + protected function getTotalAmountOfItems(): int + { + return $this->allCount; + } + + protected function getAmountOfItemsOnCurrentPage(): int + { + return count($this->paginatedItems); + } +} diff --git a/typo3/sysext/reactions/Classes/Reaction/CreateRecordReaction.php b/typo3/sysext/reactions/Classes/Reaction/CreateRecordReaction.php new file mode 100644 index 0000000000000000000000000000000000000000..d6e2304a2c6f4b8f8cdd405c8e4bd3a209c9fed8 --- /dev/null +++ b/typo3/sysext/reactions/Classes/Reaction/CreateRecordReaction.php @@ -0,0 +1,133 @@ +<?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\Reactions\Reaction; + +use Psr\Http\Message\ResponseFactoryInterface; +use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\ServerRequestInterface; +use Psr\Http\Message\StreamFactoryInterface; +use TYPO3\CMS\Core\DataHandling\DataHandler; +use TYPO3\CMS\Core\Utility\ArrayUtility; +use TYPO3\CMS\Core\Utility\Exception\MissingArrayPathException; +use TYPO3\CMS\Core\Utility\GeneralUtility; +use TYPO3\CMS\Core\Utility\StringUtility; +use TYPO3\CMS\Reactions\Model\ReactionInstruction; + +/** + * A reaction that creates a database record based on the payload within a request. + * + * @internal This is a specific reaction implementation and is not considered part of the Public TYPO3 API. + */ +class CreateRecordReaction implements ReactionInterface +{ + public function __construct( + private readonly ResponseFactoryInterface $responseFactory, + private readonly StreamFactoryInterface $streamFactory, + ) { + } + + public static function getType(): string + { + return 'create-record'; + } + + public static function getDescription(): string + { + return 'LLL:EXT:reactions/Resources/Private/Language/locallang_db.xlf:sys_reaction.reactiontype.create_record'; + } + + public static function getIconIdentifier(): string + { + return 'mimetypes-x-sys_reaction'; + } + + public function react(ServerRequestInterface $request, array $payload, ReactionInstruction $reaction): ResponseInterface + { + // @todo: Response needs to be based on given accept headers + + $user = $request->getAttribute('backend.user'); + $table = (string)($reaction->toArray()['table_name'] ?? ''); + $fields = (array)($reaction->toArray()['fields'] ?? []); + + if ($table === '' || !isset($GLOBALS['TCA'][$table])) { + return $this->jsonResponse(['success' => false, 'error' => 'Invalid argument "table_name"'], 400); + } + + if ($fields === []) { + return $this->jsonResponse(['success' => false, 'error' => 'No fields given.'], 400); + } + + $dataHandlerData = []; + foreach ($fields as $fieldName => $value) { + $dataHandlerData[$fieldName] = $this->replacePlaceHolders($value, $payload); + } + $dataHandlerData['pid'] = (int)($reaction->toArray()['storage_pid'] ?? 0); + + $data[$table][StringUtility::getUniqueId('NEW')] = $dataHandlerData; + $dataHandler = GeneralUtility::makeInstance(DataHandler::class); + $dataHandler->start($data, [], $user); + $dataHandler->process_datamap(); + + return $this->buildResponseFromDataHandler($dataHandler, 201); + } + + /** + * @internal only public due to tests + */ + public function replacePlaceHolders(mixed $value, array $payload): string + { + if (is_string($value)) { + $re = '/\$\{([^\}]*)\}/m'; + preg_match_all($re, $value, $matches, PREG_SET_ORDER, 0); + foreach ($matches as $match) { + try { + $value = str_replace($match[0], (string)ArrayUtility::getValueByPath($payload, $match[1], '.'), $value); + } catch (MissingArrayPathException) { + // Ignore this exception to show the user that there was no placeholder in the payload + } + } + } + return $value; + } + + protected function buildResponseFromDataHandler(DataHandler $dataHandler, int $successCode = 200): ResponseInterface + { + // Success depends on whether at least one NEW id has been substituted + $success = $dataHandler->substNEWwithIDs !== [] && $dataHandler->substNEWwithIDs_table !== []; + + $statusCode = $successCode; + $data = [ + 'success' => $success, + ]; + + if (!$success) { + $statusCode = 400; + $data['error'] = 'Record could not be created'; + } + + return $this->jsonResponse($data, $statusCode); + } + + protected function jsonResponse(array $data, int $statusCode = 200): ResponseInterface + { + return $this->responseFactory + ->createResponse($statusCode) + ->withHeader('Content-Type', 'application/json') + ->withBody($this->streamFactory->createStream((string)json_encode($data))); + } +} diff --git a/typo3/sysext/reactions/Classes/Reaction/ReactionInterface.php b/typo3/sysext/reactions/Classes/Reaction/ReactionInterface.php new file mode 100644 index 0000000000000000000000000000000000000000..71f40e600241118a6acfe3b38adb1887dc30b104 --- /dev/null +++ b/typo3/sysext/reactions/Classes/Reaction/ReactionInterface.php @@ -0,0 +1,45 @@ +<?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\Reactions\Reaction; + +use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\ServerRequestInterface; +use TYPO3\CMS\Reactions\Model\ReactionInstruction; + +interface ReactionInterface +{ + /** + * The reaction type, used for the registry and stored in the database + */ + public static function getType(): string; + + /** + * A meaningful description for the reaction + */ + public static function getDescription(): string; + + /** + * An icon identifier for the reaction + */ + public static function getIconIdentifier(): string; + + /** + * Main method of the reaction, handling the incoming request + */ + public function react(ServerRequestInterface $request, array $payload, ReactionInstruction $reaction): ResponseInterface; +} diff --git a/typo3/sysext/reactions/Classes/ReactionRegistry.php b/typo3/sysext/reactions/Classes/ReactionRegistry.php new file mode 100644 index 0000000000000000000000000000000000000000..f233447ed87a5f1b6fadd5c8251202d5b19e1e11 --- /dev/null +++ b/typo3/sysext/reactions/Classes/ReactionRegistry.php @@ -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\Reactions; + +use TYPO3\CMS\Reactions\Reaction\ReactionInterface; + +/** + * Registry contains all possible reaction types which are available to the system + * + * @internal + */ +class ReactionRegistry +{ + /** + * @param \IteratorAggregate<ReactionInterface> $registeredReactions + */ + public function __construct( + private readonly \IteratorAggregate $registeredReactions + ) { + } + + /** + * @return \IteratorAggregate<ReactionInterface> + */ + public function getAvailableReactionTypes(): \IteratorAggregate + { + return $this->registeredReactions; + } + + public function getReactionByType(string $type): ?ReactionInterface + { + return iterator_to_array($this->registeredReactions->getIterator())[$type] ?? null; + } +} diff --git a/typo3/sysext/reactions/Classes/Repository/ReactionDemand.php b/typo3/sysext/reactions/Classes/Repository/ReactionDemand.php new file mode 100644 index 0000000000000000000000000000000000000000..3504e52e139773b7d8456d18364734f6ec7ddd58 --- /dev/null +++ b/typo3/sysext/reactions/Classes/Repository/ReactionDemand.php @@ -0,0 +1,140 @@ +<?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\Reactions\Repository; + +use Psr\Http\Message\ServerRequestInterface; + +/** + * Demand Object for filtering reactions in the backend module + * + * @internal + */ +class ReactionDemand +{ + protected const ORDER_DESCENDING = 'desc'; + protected const ORDER_ASCENDING = 'asc'; + protected const DEFAULT_ORDER_FIELD = 'name'; + protected const ORDER_FIELDS = ['name']; + + protected int $limit = 15; + + public function __construct( + protected int $page = 1, + protected string $orderField = self::DEFAULT_ORDER_FIELD, + protected string $orderDirection = self::ORDER_ASCENDING, + protected string $name = '', + // @todo Rename to $reactionType + protected string $reaction = '' + ) { + if (!in_array($orderField, self::ORDER_FIELDS, true)) { + $orderField = self::DEFAULT_ORDER_FIELD; + } + $this->orderField = $orderField; + if (!in_array($orderDirection, [self::ORDER_DESCENDING, self::ORDER_ASCENDING], true)) { + $orderDirection = self::ORDER_ASCENDING; + } + $this->orderDirection = $orderDirection; + } + + public static function fromRequest(ServerRequestInterface $request): self + { + $page = (int)($request->getQueryParams()['page'] ?? $request->getParsedBody()['page'] ?? 1); + $orderField = (string)($request->getQueryParams()['orderField'] ?? $request->getParsedBody()['orderField'] ?? self::DEFAULT_ORDER_FIELD); + $orderDirection = (string)($request->getQueryParams()['orderDirection'] ?? $request->getParsedBody()['orderDirection'] ?? self::ORDER_ASCENDING); + $demand = $request->getQueryParams()['demand'] ?? $request->getParsedBody()['demand'] ?? []; + if (!is_array($demand) || $demand === []) { + return new self($page, $orderField, $orderDirection); + } + $name = (string)($demand['name'] ?? ''); + $reaction = (string)($demand['reaction'] ?? ''); + return new self($page, $orderField, $orderDirection, $name, $reaction); + } + + public function getOrderField(): string + { + return $this->orderField; + } + + public function getOrderDirection(): string + { + return $this->orderDirection; + } + + public function getDefaultOrderDirection(): string + { + return self::ORDER_ASCENDING; + } + + public function getReverseOrderDirection(): string + { + return $this->orderDirection === self::ORDER_ASCENDING ? self::ORDER_DESCENDING : self::ORDER_ASCENDING; + } + + public function getName(): string + { + return $this->name; + } + + public function hasName(): bool + { + return $this->name !== ''; + } + + public function getReaction(): string + { + return $this->reaction; + } + + public function hasReaction(): bool + { + return $this->reaction !== ''; + } + + public function hasConstraints(): bool + { + return $this->hasName() + || $this->hasReaction(); + } + + public function getPage(): int + { + return $this->page; + } + + public function getLimit(): int + { + return $this->limit; + } + + public function getOffset(): int + { + return ($this->page - 1) * $this->limit; + } + + public function getParameters(): array + { + $parameters = []; + if ($this->hasName()) { + $parameters['name'] = $this->getName(); + } + if ($this->hasReaction()) { + $parameters['reaction'] = $this->getReaction(); + } + return $parameters; + } +} diff --git a/typo3/sysext/reactions/Classes/Repository/ReactionRepository.php b/typo3/sysext/reactions/Classes/Repository/ReactionRepository.php new file mode 100644 index 0000000000000000000000000000000000000000..d913297dfeb2026ece8054201dd8e90944459b0f --- /dev/null +++ b/typo3/sysext/reactions/Classes/Repository/ReactionRepository.php @@ -0,0 +1,153 @@ +<?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\Reactions\Repository; + +use TYPO3\CMS\Backend\Utility\BackendUtility; +use TYPO3\CMS\Core\Database\ConnectionPool; +use TYPO3\CMS\Core\Database\Query\QueryBuilder; +use TYPO3\CMS\Core\Database\Query\Restriction\DeletedRestriction; +use TYPO3\CMS\Core\Database\Query\Restriction\EndTimeRestriction; +use TYPO3\CMS\Core\Database\Query\Restriction\HiddenRestriction; +use TYPO3\CMS\Core\Database\Query\Restriction\StartTimeRestriction; +use TYPO3\CMS\Core\Utility\GeneralUtility; +use TYPO3\CMS\Reactions\Model\ReactionInstruction; + +/** + * Accessing reaction records from the database + * + * @internal This class is not part of TYPO3's Core API. + */ +class ReactionRepository +{ + public function findAll(): array + { + return $this->map($this->getQueryBuilder() + ->select('*') + ->from('sys_reaction') + ->executeQuery() + ->fetchAllAssociative()); + } + + public function countAll(): int + { + return (int)$this->getQueryBuilder() + ->count('*') + ->from('sys_reaction') + ->executeQuery() + ->fetchOne(); + } + + public function getReactionRecords(?ReactionDemand $demand = null): array + { + return $demand !== null ? $this->findByDemand($demand) : $this->findAll(); + } + + /** + * Used within the resolving / execution process, so starttime / endtime is added. + */ + public function getReactionRecordByIdentifier(string $identifier): ?ReactionInstruction + { + $queryBuilder = $this->getQueryBuilder(); + $queryBuilder + ->getRestrictions() + ->add(GeneralUtility::makeInstance(HiddenRestriction::class)) + ->add(GeneralUtility::makeInstance(StartTimeRestriction::class)) + ->add(GeneralUtility::makeInstance(EndTimeRestriction::class)); + $result = $queryBuilder + ->select('*') + ->from('sys_reaction') + ->where( + $queryBuilder->expr()->eq('identifier', $queryBuilder->createNamedParameter($identifier)) + ) + ->executeQuery() + ->fetchAssociative(); + if (!empty($result)) { + return $this->mapSingleRow($result); + } + return null; + } + + public function findByDemand(ReactionDemand $demand): array + { + return $this->map($this->getQueryBuilderForDemand($demand) + ->setMaxResults($demand->getLimit()) + ->setFirstResult($demand->getOffset()) + ->executeQuery() + ->fetchAllAssociative()); + } + + protected function getQueryBuilderForDemand(ReactionDemand $demand): QueryBuilder + { + $queryBuilder = $this->getQueryBuilder(); + $queryBuilder + ->select('*') + ->from('sys_reaction'); + + $queryBuilder->orderBy( + $demand->getOrderField(), + $demand->getOrderDirection() + ); + + $constraints = []; + if ($demand->hasName()) { + $escapedLikeString = '%' . $queryBuilder->escapeLikeWildcards($demand->getName()) . '%'; + $constraints[] = $queryBuilder->expr()->like( + 'name', + $queryBuilder->createNamedParameter($escapedLikeString) + ); + } + if ($demand->hasReaction()) { + $constraints[] = $queryBuilder->expr()->eq( + 'reactiontype', + $queryBuilder->createNamedParameter($demand->getReaction()) + ); + } + + if (!empty($constraints)) { + $queryBuilder->where(...$constraints); + } + return $queryBuilder; + } + + protected function map(array $rows): array + { + $items = []; + foreach ($rows as $row) { + $items[] = $this->mapSingleRow($row); + } + return $items; + } + + protected function mapSingleRow(array $row): ReactionInstruction + { + $row = BackendUtility::convertDatabaseRowValuesToPhp('sys_reaction', $row); + return new ReactionInstruction($row); + } + + protected function getQueryBuilder(): QueryBuilder + { + // @todo ConnectionPool could be injected + $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class) + ->getQueryBuilderForTable('sys_reaction'); + $queryBuilder->getRestrictions() + ->removeAll() + ->add(GeneralUtility::makeInstance(DeletedRestriction::class)); + return $queryBuilder + ->orderBy('name'); + } +} diff --git a/typo3/sysext/reactions/Configuration/Backend/Modules.php b/typo3/sysext/reactions/Configuration/Backend/Modules.php new file mode 100644 index 0000000000000000000000000000000000000000..420ead05a8f26189c9ed57c41685b5e6c9bcd664 --- /dev/null +++ b/typo3/sysext/reactions/Configuration/Backend/Modules.php @@ -0,0 +1,23 @@ +<?php + +use TYPO3\CMS\Reactions\Controller\ManagementController; + +/** + * Definitions for modules provided by EXT:reactions + */ +return [ + 'system_reactions' => [ + 'parent' => 'system', + 'position' => ['after' => 'system_BeuserTxBeuser'], + 'access' => 'admin', + 'workspaces' => 'live', + 'path' => '/module/system/reactions', + 'iconIdentifier' => 'module-reactions', + 'labels' => 'LLL:EXT:reactions/Resources/Private/Language/locallang_module_reactions.xlf', + 'routes' => [ + '_default' => [ + 'target' => ManagementController::class . '::handleRequest', + ], + ], + ], +]; diff --git a/typo3/sysext/reactions/Configuration/Backend/Routes.php b/typo3/sysext/reactions/Configuration/Backend/Routes.php new file mode 100644 index 0000000000000000000000000000000000000000..c18bcc3f7b7221271dee71a6d84844ce5e0b04ad --- /dev/null +++ b/typo3/sysext/reactions/Configuration/Backend/Routes.php @@ -0,0 +1,13 @@ +<?php + +/** + * Definitions for routes provided by EXT:reactions + */ +return [ + 'reaction' => [ + 'path' => '/reaction/{reactionIdentifier?}', + 'access' => 'public', + 'methods' => ['POST'], + 'target' => \TYPO3\CMS\Reactions\Http\ReactionHandler::class . '::handleReaction', + ], +]; diff --git a/typo3/sysext/reactions/Configuration/Icons.php b/typo3/sysext/reactions/Configuration/Icons.php new file mode 100644 index 0000000000000000000000000000000000000000..264e469a860a2077805997630b8d2f79eb692217 --- /dev/null +++ b/typo3/sysext/reactions/Configuration/Icons.php @@ -0,0 +1,14 @@ +<?php + +use TYPO3\CMS\Core\Imaging\IconProvider\SvgIconProvider; + +return [ + 'mimetypes-x-sys_reaction' => [ + 'provider' => SvgIconProvider::class, + 'source' => 'EXT:reactions/Resources/Public/Icons/mimetypes-x-sys_reaction.svg', + ], + 'module-reactions' => [ + 'provider' => SvgIconProvider::class, + 'source' => 'EXT:reactions/Resources/Public/Icons/Extension.svg', + ], +]; diff --git a/typo3/sysext/reactions/Configuration/RequestMiddlewares.php b/typo3/sysext/reactions/Configuration/RequestMiddlewares.php new file mode 100644 index 0000000000000000000000000000000000000000..49e5980b27b855a1d371043985e2640c60c7c416 --- /dev/null +++ b/typo3/sysext/reactions/Configuration/RequestMiddlewares.php @@ -0,0 +1,18 @@ +<?php + +/** + * Definitions for middlewares provided by EXT:reactions + */ + +use TYPO3\CMS\Reactions\Http\Middleware\ReactionResolver; + +return [ + 'backend' => [ + 'typo3/cms-reactions/resolver' => [ + 'target' => ReactionResolver::class, + 'before' => [ + 'typo3/cms-backend/authentication', + ], + ], + ], +]; diff --git a/typo3/sysext/reactions/Configuration/Services.yaml b/typo3/sysext/reactions/Configuration/Services.yaml new file mode 100644 index 0000000000000000000000000000000000000000..a9bdfc334bdaf1f4f8b45879e047b174c3691778 --- /dev/null +++ b/typo3/sysext/reactions/Configuration/Services.yaml @@ -0,0 +1,21 @@ +services: + _defaults: + autowire: true + autoconfigure: true + public: false + + _instanceof: + TYPO3\CMS\Reactions\Reaction\ReactionInterface: + tags: ['reactions.reaction'] + public: true + lazy: true + + TYPO3\CMS\Reactions\: + resource: '../Classes/*' + + TYPO3\CMS\Reactions\ReactionRegistry: + arguments: + $registeredReactions: !tagged_iterator { tag: 'reactions.reaction', default_index_method: 'getType' } + + TYPO3\CMS\Reactions\Form\ReactionItemsProcFunc: + public: true diff --git a/typo3/sysext/reactions/Configuration/TCA/Overrides/sys_reaction_create_record.php b/typo3/sysext/reactions/Configuration/TCA/Overrides/sys_reaction_create_record.php new file mode 100644 index 0000000000000000000000000000000000000000..5225e993c8527f88a1fad5a95aab444e0d75ae1f --- /dev/null +++ b/typo3/sysext/reactions/Configuration/TCA/Overrides/sys_reaction_create_record.php @@ -0,0 +1,76 @@ +<?php + +\TYPO3\CMS\Core\Utility\ExtensionManagementUtility::addTCAcolumns( + 'sys_reaction', + [ + 'table_name' => [ + 'label' => 'LLL:EXT:reactions/Resources/Private/Language/locallang_db.xlf:sys_reaction.table_name', + 'description' => 'LLL:EXT:reactions/Resources/Private/Language/locallang_db.xlf:sys_reaction.table_name.description', + 'onChange' => 'reload', + 'config' => [ + 'type' => 'select', + 'renderType' => 'selectSingle', + 'required' => true, + 'default' => '', + 'items' => [ + ['LLL:EXT:reactions/Resources/Private/Language/locallang_db.xlf:sys_reaction.table_name.select', ''], + ], + 'itemsProcFunc' => \TYPO3\CMS\Reactions\Form\ReactionItemsProcFunc::class . '->populateAvailableContentTables', + ], + ], + 'storage_pid' => [ + 'label' => 'LLL:EXT:reactions/Resources/Private/Language/locallang_db.xlf:sys_reaction.storage_pid', + 'description' => 'LLL:EXT:reactions/Resources/Private/Language/locallang_db.xlf:sys_reaction.storage_pid.description', + 'config' => [ + 'type' => 'group', + 'allowed' => 'pages', + 'size' => 1, + 'maxitems' => 1, + ], + ], + 'fields' => [ + 'label' => 'LLL:EXT:reactions/Resources/Private/Language/locallang_db.xlf:sys_reaction.fields', + 'description' => 'LLL:EXT:reactions/Resources/Private/Language/locallang_db.xlf:sys_reaction.fields.description', + 'displayCond' => 'FIELD:table_name:REQ:true', + 'config' => [ + 'type' => 'user', + 'renderType' => 'fieldMap', + 'dbType' => 'json', + 'default' => '{}', + ], + ], + ] +); + +\TYPO3\CMS\Core\Utility\ExtensionManagementUtility::addTcaSelectItem( + 'sys_reaction', + 'reactiontype', + [ + \TYPO3\CMS\Reactions\Reaction\CreateRecordReaction::getDescription(), + \TYPO3\CMS\Reactions\Reaction\CreateRecordReaction::getType(), + \TYPO3\CMS\Reactions\Reaction\CreateRecordReaction::getIconIdentifier(), + ] +); + +$GLOBALS['TCA']['sys_reaction']['ctrl']['typeicon_classes'][\TYPO3\CMS\Reactions\Reaction\CreateRecordReaction::getType()] = \TYPO3\CMS\Reactions\Reaction\CreateRecordReaction::getIconIdentifier(); + +$GLOBALS['TCA']['sys_reaction']['palettes']['createRecord'] = [ + 'label' => 'LLL:EXT:reactions/Resources/Private/Language/locallang_db.xlf:palette.additional', + 'showitem' => 'table_name, --linebreak--, storage_pid, impersonate_user, --linebreak--, fields', +]; + +$GLOBALS['TCA']['sys_reaction']['types'][\TYPO3\CMS\Reactions\Reaction\CreateRecordReaction::getType()] = [ + 'showitem' => ' + --div--;LLL:EXT:core/Resources/Private/Language/Form/locallang_tabs.xlf:general, + --palette--;;config, + --palette--;;createRecord, + --div--;LLL:EXT:core/Resources/Private/Language/Form/locallang_tabs.xlf:access, + --palette--;;access', + 'columnsOverrides' => [ + 'impersonate_user' => [ + 'config' => [ + 'minitems' => 1, + ], + ], + ], +]; diff --git a/typo3/sysext/reactions/Configuration/TCA/sys_reaction.php b/typo3/sysext/reactions/Configuration/TCA/sys_reaction.php new file mode 100644 index 0000000000000000000000000000000000000000..a6fbafcb06530d12647a0070a2421c9303a1a590 --- /dev/null +++ b/typo3/sysext/reactions/Configuration/TCA/sys_reaction.php @@ -0,0 +1,149 @@ +<?php + +return [ + 'ctrl' => [ + 'title' => 'LLL:EXT:reactions/Resources/Private/Language/locallang_db.xlf:sys_reaction', + 'label' => 'name', + 'descriptionColumn' => 'description', + 'crdate' => 'createdon', + 'tstamp' => 'updatedon', + 'adminOnly' => true, + 'rootLevel' => 1, + 'groupName' => 'system', + 'default_sortby' => 'name', + 'type' => 'reactiontype', + 'typeicon_column' => 'reactiontype', + 'typeicon_classes' => [ + 'default' => 'mimetypes-x-sys_reaction', + ], + 'delete' => 'deleted', + 'enablecolumns' => [ + 'disabled' => 'disabled', + 'starttime' => 'starttime', + 'endtime' => 'endtime', + ], + 'searchFields' => 'name,secret', + 'versioningWS_alwaysAllowLiveEdit' => true, + ], + 'types' => [ + '1' => [ + 'showitem' => ' + --div--;LLL:EXT:core/Resources/Private/Language/Form/locallang_tabs.xlf:general, + --palette--;;config, + --div--;LLL:EXT:core/Resources/Private/Language/Form/locallang_tabs.xlf:access, + --palette--;;access', + ], + ], + 'palettes' => [ + 'config' => [ + 'label' => 'LLL:EXT:reactions/Resources/Private/Language/locallang_db.xlf:palette.config', + 'description' => 'LLL:EXT:reactions/Resources/Private/Language/locallang_db.xlf:palette.config.description', + 'showitem' => 'reactiontype, --linebreak--, name, description, --linebreak--, identifier, secret', + ], + 'access' => [ + 'label' => 'LLL:EXT:frontend/Resources/Private/Language/locallang_tca.xlf:pages.palettes.access', + 'showitem' => 'disabled, starttime, endtime', + ], + ], + 'columns' => [ + 'reactiontype' => [ + 'label' => 'LLL:EXT:reactions/Resources/Private/Language/locallang_db.xlf:sys_reaction.reactiontype', + 'description' => 'LLL:EXT:reactions/Resources/Private/Language/locallang_db.xlf:sys_reaction.reactiontype.description', + 'config' => [ + 'type' => 'select', + 'renderType' => 'selectSingle', + 'required' => true, + 'items' => [ + ['LLL:EXT:reactions/Resources/Private/Language/locallang_db.xlf:sys_reaction.reactiontype.select', ''], + ], + ], + ], + 'name' => [ + 'label' => 'LLL:EXT:reactions/Resources/Private/Language/locallang_db.xlf:sys_reaction.name', + 'description' => 'LLL:EXT:reactions/Resources/Private/Language/locallang_db.xlf:sys_reaction.name.description', + 'config' => [ + 'type' => 'input', + 'required' => true, + 'eval' => 'trim', + ], + ], + 'description' => [ + 'label' => 'LLL:EXT:reactions/Resources/Private/Language/locallang_db.xlf:sys_reaction.description', + 'description' => 'LLL:EXT:reactions/Resources/Private/Language/locallang_db.xlf:sys_reaction.description.description', + 'config' => [ + 'type' => 'text', + 'rows' => 5, + 'cols' => 30, + ], + ], + 'identifier' => [ + 'label' => 'LLL:EXT:reactions/Resources/Private/Language/locallang_db.xlf:sys_reaction.identifier', + 'description' => 'LLL:EXT:reactions/Resources/Private/Language/locallang_db.xlf:sys_reaction.identifier.description', + 'config' => [ + 'type' => 'user', + 'renderType' => 'uuid', + ], + ], + 'secret' => [ + 'label' => 'LLL:EXT:reactions/Resources/Private/Language/locallang_db.xlf:sys_reaction.secret', + 'description' => 'LLL:EXT:reactions/Resources/Private/Language/locallang_db.xlf:sys_reaction.secret.description', + 'config' => [ + 'type' => 'password', + 'required' => true, + 'fieldControl' => [ + 'passwordGenerator' => [ + 'renderType' => 'passwordGenerator', + 'options' => [ + 'title' => 'LLL:EXT:reactions/Resources/Private/Language/locallang_db.xlf:sys_reaction.secret.passwordGenerator', + 'allowEdit' => false, + 'passwordRules' => [ + 'length' => 40, + 'random' => 'hex', + ], + ], + ], + ], + ], + ], + 'impersonate_user' => [ + 'label' => 'LLL:EXT:reactions/Resources/Private/Language/locallang_db.xlf:sys_reaction.impersonate_user', + 'description' => 'LLL:EXT:reactions/Resources/Private/Language/locallang_db.xlf:sys_reaction.impersonate_user.description', + 'config' => [ + 'type' => 'group', + 'allowed' => 'be_users', + 'size' => 1, + 'maxitems' => 1, + ], + ], + 'disabled' => [ + 'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_general.xlf:LGL.enabled', + 'config' => [ + 'type' => 'check', + 'renderType' => 'checkboxToggle', + 'items' => [ + [ + 0 => '', + 'invertStateDisplay' => true, + ], + ], + ], + ], + 'starttime' => [ + 'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_general.xlf:LGL.starttime', + 'config' => [ + 'type' => 'datetime', + 'default' => 0, + ], + ], + 'endtime' => [ + 'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_general.xlf:LGL.endtime', + 'config' => [ + 'type' => 'datetime', + 'default' => 0, + 'range' => [ + 'upper' => mktime(0, 0, 0, 1, 1, 2038), + ], + ], + ], + ], +]; diff --git a/typo3/sysext/reactions/LICENSE.txt b/typo3/sysext/reactions/LICENSE.txt new file mode 100644 index 0000000000000000000000000000000000000000..d159169d1050894d3ea3b98e1c965c4058208fe1 --- /dev/null +++ b/typo3/sysext/reactions/LICENSE.txt @@ -0,0 +1,339 @@ + GNU GENERAL PUBLIC LICENSE + Version 2, June 1991 + + Copyright (C) 1989, 1991 Free Software Foundation, Inc., + 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The licenses for most software are designed to take away your +freedom to share and change it. By contrast, the GNU General Public +License is intended to guarantee your freedom to share and change free +software--to make sure the software is free for all its users. This +General Public License applies to most of the Free Software +Foundation's software and to any other program whose authors commit to +using it. (Some other Free Software Foundation software is covered by +the GNU Lesser General Public License instead.) You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +this service if you wish), that you receive source code or can get it +if you want it, that you can change the software or use pieces of it +in new free programs; and that you know you can do these things. + + To protect your rights, we need to make restrictions that forbid +anyone to deny you these rights or to ask you to surrender the rights. +These restrictions translate to certain responsibilities for you if you +distribute copies of the software, or if you modify it. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must give the recipients all the rights that +you have. You must make sure that they, too, receive or can get the +source code. And you must show them these terms so they know their +rights. + + We protect your rights with two steps: (1) copyright the software, and +(2) offer you this license which gives you legal permission to copy, +distribute and/or modify the software. + + Also, for each author's protection and ours, we want to make certain +that everyone understands that there is no warranty for this free +software. If the software is modified by someone else and passed on, we +want its recipients to know that what they have is not the original, so +that any problems introduced by others will not reflect on the original +authors' reputations. + + Finally, any free program is threatened constantly by software +patents. We wish to avoid the danger that redistributors of a free +program will individually obtain patent licenses, in effect making the +program proprietary. To prevent this, we have made it clear that any +patent must be licensed for everyone's free use or not licensed at all. + + The precise terms and conditions for copying, distribution and +modification follow. + + GNU GENERAL PUBLIC LICENSE + TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + + 0. This License applies to any program or other work which contains +a notice placed by the copyright holder saying it may be distributed +under the terms of this General Public License. The "Program", below, +refers to any such program or work, and a "work based on the Program" +means either the Program or any derivative work under copyright law: +that is to say, a work containing the Program or a portion of it, +either verbatim or with modifications and/or translated into another +language. (Hereinafter, translation is included without limitation in +the term "modification".) Each licensee is addressed as "you". + +Activities other than copying, distribution and modification are not +covered by this License; they are outside its scope. The act of +running the Program is not restricted, and the output from the Program +is covered only if its contents constitute a work based on the +Program (independent of having been made by running the Program). +Whether that is true depends on what the Program does. + + 1. You may copy and distribute verbatim copies of the Program's +source code as you receive it, in any medium, provided that you +conspicuously and appropriately publish on each copy an appropriate +copyright notice and disclaimer of warranty; keep intact all the +notices that refer to this License and to the absence of any warranty; +and give any other recipients of the Program a copy of this License +along with the Program. + +You may charge a fee for the physical act of transferring a copy, and +you may at your option offer warranty protection in exchange for a fee. + + 2. You may modify your copy or copies of the Program or any portion +of it, thus forming a work based on the Program, and copy and +distribute such modifications or work under the terms of Section 1 +above, provided that you also meet all of these conditions: + + a) You must cause the modified files to carry prominent notices + stating that you changed the files and the date of any change. + + b) You must cause any work that you distribute or publish, that in + whole or in part contains or is derived from the Program or any + part thereof, to be licensed as a whole at no charge to all third + parties under the terms of this License. + + c) If the modified program normally reads commands interactively + when run, you must cause it, when started running for such + interactive use in the most ordinary way, to print or display an + announcement including an appropriate copyright notice and a + notice that there is no warranty (or else, saying that you provide + a warranty) and that users may redistribute the program under + these conditions, and telling the user how to view a copy of this + License. (Exception: if the Program itself is interactive but + does not normally print such an announcement, your work based on + the Program is not required to print an announcement.) + +These requirements apply to the modified work as a whole. If +identifiable sections of that work are not derived from the Program, +and can be reasonably considered independent and separate works in +themselves, then this License, and its terms, do not apply to those +sections when you distribute them as separate works. But when you +distribute the same sections as part of a whole which is a work based +on the Program, the distribution of the whole must be on the terms of +this License, whose permissions for other licensees extend to the +entire whole, and thus to each and every part regardless of who wrote it. + +Thus, it is not the intent of this section to claim rights or contest +your rights to work written entirely by you; rather, the intent is to +exercise the right to control the distribution of derivative or +collective works based on the Program. + +In addition, mere aggregation of another work not based on the Program +with the Program (or with a work based on the Program) on a volume of +a storage or distribution medium does not bring the other work under +the scope of this License. + + 3. You may copy and distribute the Program (or a work based on it, +under Section 2) in object code or executable form under the terms of +Sections 1 and 2 above provided that you also do one of the following: + + a) Accompany it with the complete corresponding machine-readable + source code, which must be distributed under the terms of Sections + 1 and 2 above on a medium customarily used for software interchange; or, + + b) Accompany it with a written offer, valid for at least three + years, to give any third party, for a charge no more than your + cost of physically performing source distribution, a complete + machine-readable copy of the corresponding source code, to be + distributed under the terms of Sections 1 and 2 above on a medium + customarily used for software interchange; or, + + c) Accompany it with the information you received as to the offer + to distribute corresponding source code. (This alternative is + allowed only for noncommercial distribution and only if you + received the program in object code or executable form with such + an offer, in accord with Subsection b above.) + +The source code for a work means the preferred form of the work for +making modifications to it. For an executable work, complete source +code means all the source code for all modules it contains, plus any +associated interface definition files, plus the scripts used to +control compilation and installation of the executable. However, as a +special exception, the source code distributed need not include +anything that is normally distributed (in either source or binary +form) with the major components (compiler, kernel, and so on) of the +operating system on which the executable runs, unless that component +itself accompanies the executable. + +If distribution of executable or object code is made by offering +access to copy from a designated place, then offering equivalent +access to copy the source code from the same place counts as +distribution of the source code, even though third parties are not +compelled to copy the source along with the object code. + + 4. You may not copy, modify, sublicense, or distribute the Program +except as expressly provided under this License. Any attempt +otherwise to copy, modify, sublicense or distribute the Program is +void, and will automatically terminate your rights under this License. +However, parties who have received copies, or rights, from you under +this License will not have their licenses terminated so long as such +parties remain in full compliance. + + 5. You are not required to accept this License, since you have not +signed it. However, nothing else grants you permission to modify or +distribute the Program or its derivative works. These actions are +prohibited by law if you do not accept this License. Therefore, by +modifying or distributing the Program (or any work based on the +Program), you indicate your acceptance of this License to do so, and +all its terms and conditions for copying, distributing or modifying +the Program or works based on it. + + 6. Each time you redistribute the Program (or any work based on the +Program), the recipient automatically receives a license from the +original licensor to copy, distribute or modify the Program subject to +these terms and conditions. You may not impose any further +restrictions on the recipients' exercise of the rights granted herein. +You are not responsible for enforcing compliance by third parties to +this License. + + 7. If, as a consequence of a court judgment or allegation of patent +infringement or for any other reason (not limited to patent issues), +conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot +distribute so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you +may not distribute the Program at all. For example, if a patent +license would not permit royalty-free redistribution of the Program by +all those who receive copies directly or indirectly through you, then +the only way you could satisfy both it and this License would be to +refrain entirely from distribution of the Program. + +If any portion of this section is held invalid or unenforceable under +any particular circumstance, the balance of the section is intended to +apply and the section as a whole is intended to apply in other +circumstances. + +It is not the purpose of this section to induce you to infringe any +patents or other property right claims or to contest validity of any +such claims; this section has the sole purpose of protecting the +integrity of the free software distribution system, which is +implemented by public license practices. Many people have made +generous contributions to the wide range of software distributed +through that system in reliance on consistent application of that +system; it is up to the author/donor to decide if he or she is willing +to distribute software through any other system and a licensee cannot +impose that choice. + +This section is intended to make thoroughly clear what is believed to +be a consequence of the rest of this License. + + 8. If the distribution and/or use of the Program is restricted in +certain countries either by patents or by copyrighted interfaces, the +original copyright holder who places the Program under this License +may add an explicit geographical distribution limitation excluding +those countries, so that distribution is permitted only in or among +countries not thus excluded. In such case, this License incorporates +the limitation as if written in the body of this License. + + 9. The Free Software Foundation may publish revised and/or new versions +of the General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + +Each version is given a distinguishing version number. If the Program +specifies a version number of this License which applies to it and "any +later version", you have the option of following the terms and conditions +either of that version or of any later version published by the Free +Software Foundation. If the Program does not specify a version number of +this License, you may choose any version ever published by the Free Software +Foundation. + + 10. If you wish to incorporate parts of the Program into other free +programs whose distribution conditions are different, write to the author +to ask for permission. For software which is copyrighted by the Free +Software Foundation, write to the Free Software Foundation; we sometimes +make exceptions for this. Our decision will be guided by the two goals +of preserving the free status of all derivatives of our free software and +of promoting the sharing and reuse of software generally. + + NO WARRANTY + + 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY +FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN +OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES +PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED +OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS +TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE +PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, +REPAIR OR CORRECTION. + + 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR +REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, +INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING +OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED +TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY +YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER +PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE +POSSIBILITY OF SUCH DAMAGES. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +convey the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + <one line to give the program's name and a brief idea of what it does.> + Copyright (C) <year> <name of author> + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along + with this program; if not, write to the Free Software Foundation, Inc., + 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +Also add information on how to contact you by electronic and paper mail. + +If the program is interactive, make it output a short notice like this +when it starts in an interactive mode: + + Gnomovision version 69, Copyright (C) year name of author + Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, the commands you use may +be called something other than `show w' and `show c'; they could even be +mouse-clicks or menu items--whatever suits your program. + +You should also get your employer (if you work as a programmer) or your +school, if any, to sign a "copyright disclaimer" for the program, if +necessary. Here is a sample; alter the names: + + Yoyodyne, Inc., hereby disclaims all copyright interest in the program + `Gnomovision' (which makes passes at compilers) written by James Hacker. + + <signature of Ty Coon>, 1 April 1989 + Ty Coon, President of Vice + +This General Public License does not permit incorporating your program into +proprietary programs. If your program is a subroutine library, you may +consider it more useful to permit linking proprietary applications with the +library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. diff --git a/typo3/sysext/reactions/README.rst b/typo3/sysext/reactions/README.rst new file mode 100644 index 0000000000000000000000000000000000000000..9208833c68b98671fadcb36200177b23097e92f2 --- /dev/null +++ b/typo3/sysext/reactions/README.rst @@ -0,0 +1,12 @@ +============================= +TYPO3 extension ``reactions`` +============================= + +This extension handles incoming Webhooks to TYPO3. It also provides +a corresponding backend module to manage reaction records in the TYPO3 +backend (System>Reactions). + +:Repository: https://github.com/typo3/typo3 +:Issues: https://forge.typo3.org/ +:Read online: https://docs.typo3.org/ +:TER: https://extensions.typo3.org/extension/reactions/ diff --git a/typo3/sysext/reactions/Resources/Private/Language/locallang_db.xlf b/typo3/sysext/reactions/Resources/Private/Language/locallang_db.xlf new file mode 100644 index 0000000000000000000000000000000000000000..49ec5e0d690f8d33c17170f1554ddd5ff96d50aa --- /dev/null +++ b/typo3/sysext/reactions/Resources/Private/Language/locallang_db.xlf @@ -0,0 +1,94 @@ +<?xml version="1.0" encoding="UTF-8"?> +<xliff version="1.2" xmlns="urn:oasis:names:tc:xliff:document:1.2"> + <file source-language="en" datatype="plaintext" original="EXT:reactions/Resources/Private/Language/locallang_db.xlf" date="2022-08-15T20:22:32Z" product-name="reactions"> + <header/> + <body> + <trans-unit id="sys_reaction" resname="sys_reaction"> + <source>System Reactions</source> + </trans-unit> + <trans-unit id="sys_reaction.name" resname="sys_reaction.name"> + <source>Name</source> + </trans-unit> + <trans-unit id="sys_reaction.name.description" resname="sys_reaction.name.description"> + <source>Meaningful name of the reaction</source> + </trans-unit> + <trans-unit id="sys_reaction.description" resname="sys_reactions.description"> + <source>Description</source> + </trans-unit> + <trans-unit id="sys_reaction.description.description" resname="sys_reactions.description.description"> + <source>Additional information about the reaction.</source> + </trans-unit> + <trans-unit id="sys_reaction.identifier" resname="sys_reaction.identifier"> + <source>Identifier</source> + </trans-unit> + <trans-unit id="sys_reaction.identifier.description" resname="sys_reaction.identifier.description"> + <source>This is your unique reaction identifier within the TYPO3 URL</source> + </trans-unit> + <trans-unit id="sys_reaction.secret" resname="sys_reaction.secret"> + <source>Secret</source> + </trans-unit> + <trans-unit id="sys_reaction.secret.description" resname="sys_reaction.secret.description"> + <source>The secret is required to call the reaction from the outside. The secret can be re-created anytime, but will only be visible once (unitl the record got saved).</source> + </trans-unit> + <trans-unit id="sys_reaction.secret.passwordGenerator" resname="sys_reaction.secret.passwordGenerator"> + <source>Generate secret token</source> + </trans-unit> + <trans-unit id="sys_reaction.reactiontype" resname="sys_reaction.reactiontype"> + <source>Reaction Type</source> + </trans-unit> + <trans-unit id="sys_reactions.reactiontype.description" resname="sys_reactions.reactiontype.description"> + <source>Select the reaction type to be configured.</source> + </trans-unit> + <trans-unit id="sys_reaction.reactiontype.select" resname="sys_reaction.reactiontype.select"> + <source>Select a reaction</source> + </trans-unit> + <trans-unit id="sys_reaction.reactiontype.create_record" resname="sys_reaction.reactiontype.create_record"> + <source>Create database record</source> + </trans-unit> + <trans-unit id="sys_reaction.impersonate_user" resname="sys_reaction.impersonate_user"> + <source>Impersonate User</source> + </trans-unit> + <trans-unit id="sys_reaction.impersonate_user.description" resname="sys_reaction.impersonate_user.description"> + <source>Select the user with the appropriate access rights that is allowed to add a record of this type. If in doubt, use the CLI user.</source> + </trans-unit> + <trans-unit id="sys_reaction.table_name" resname="sys_reaction.table_name"> + <source>Table</source> + </trans-unit> + <trans-unit id="sys_reaction.table_name.description" resname="sys_reaction.table_name.description"> + <source>Select one tables to display the corresponding fields.</source> + </trans-unit> + <trans-unit id="sys_reaction.table_name.select" resname="sys_reaction.table_name.select"> + <source>Select a table</source> + </trans-unit> + <trans-unit id="sys_reaction.storage_pid" resname="sys_reaction.storage_pid"> + <source>Storage PID</source> + </trans-unit> + <trans-unit id="sys_reaction.storage_pid.description" resname="sys_reaction.storage_pid.description"> + <source>Select the page on which a new record is created on.</source> + </trans-unit> + <trans-unit id="sys_reaction.fields" resname="sys_reaction.fields"> + <source>Fields</source> + </trans-unit> + <trans-unit id="sys_reaction.fields.description" resname="sys_reaction.fields.description"> + <source>The available fields depend on the selected table. The usage of placeholders like ${foo.bar} is possible.</source> + </trans-unit> + + <trans-unit id="palette.config" resname="palette.config"> + <source>Configuration</source> + </trans-unit> + <trans-unit id="palette.config.description" resname="palette.config.description"> + <source>Generel configuration of the reaction.</source> + </trans-unit> + <trans-unit id="palette.additional" resname="palette.additional"> + <source>Additional configuration</source> + </trans-unit> + <trans-unit id="palette.additional.description" resname="palette.additional.description"> + <source>Specific configuration for the selected reaction type</source> + </trans-unit> + + <trans-unit id="fieldMapElement.noFields" resname="fieldMapElement.noFields"> + <source>The selected table '%s' does not define any valid fields to be configured.</source> + </trans-unit> + </body> + </file> +</xliff> diff --git a/typo3/sysext/reactions/Resources/Private/Language/locallang_module_reactions.xlf b/typo3/sysext/reactions/Resources/Private/Language/locallang_module_reactions.xlf new file mode 100644 index 0000000000000000000000000000000000000000..af02b1e0a13148591f9926ebb05cdb99a08b8263 --- /dev/null +++ b/typo3/sysext/reactions/Resources/Private/Language/locallang_module_reactions.xlf @@ -0,0 +1,77 @@ +<?xml version="1.0" encoding="UTF-8"?> +<xliff version="1.2" xmlns="urn:oasis:names:tc:xliff:document:1.2"> + <file source-language="en" datatype="plaintext" original="EXT:reactions/Resources/Private/Language/locallang_module_reactions.xlf" date="2022-08-17T12:12:34Z" product-name="reactions"> + <header/> + <body> + <trans-unit id="mlang_labels_tablabel" resname="mlang_labels_tablabel"> + <source>Reaction Administration</source> + </trans-unit> + <trans-unit id="mlang_labels_tabdescr" resname="mlang_labels_tabdescr"> + <source>This is the administration area for Reactions.</source> + </trans-unit> + <trans-unit id="mlang_tabs_tab" resname="mlang_tabs_tab"> + <source>Reactions</source> + </trans-unit> + + <trans-unit id="heading_text" resname="heading_text"> + <source>Reactions — Manage Incoming HTTP Web Hooks</source> + </trans-unit> + <trans-unit id="reaction_not_found.title" resname="reaction_not_found.title"> + <source>No reactions found!</source> + </trans-unit> + <trans-unit id="reaction_not_found.message" resname="reaction_not_found.message"> + <source>There are currently no reaction records found in the database.</source> + </trans-unit> + <trans-unit id="reaction_create" resname="reaction_create"> + <source>Create new reaction</source> + </trans-unit> + <trans-unit id="reaction_not_found_with_filter.title" resname="reaction_not_found_with_filter.title"> + <source>No reactions found!</source> + </trans-unit> + <trans-unit id="reaction_not_found_with_filter.message" resname="reaction_not_found_with_filter.message"> + <source>With the current set of filters applied, no reactions could be found.</source> + </trans-unit> + <trans-unit id="reaction_no_filter" resname="reaction_no_filter"> + <source>Remove all filter</source> + </trans-unit> + <trans-unit id="reaction_example" resname="reaction_example"> + <source>Example</source> + </trans-unit> + + <trans-unit id="filter.sendButton" resname="filter.sendButton"> + <source>Filter</source> + </trans-unit> + <trans-unit id="filter.resetButton" resname="filter.resetButton"> + <source>Reset</source> + </trans-unit> + <trans-unit id="filter.reaction" resname="filter.reaction"> + <source>Reaction Type</source> + </trans-unit> + <trans-unit id="filter.reaction.showAll" resname="filter.reaction.showAll"> + <source>Show all</source> + </trans-unit> + + <trans-unit id="pagination.previous" resname="pagination.previous"> + <source>previous</source> + </trans-unit> + <trans-unit id="pagination.next" resname="pagination.next"> + <source>next</source> + </trans-unit> + <trans-unit id="pagination.first" resname="pagination.first"> + <source>first</source> + </trans-unit> + <trans-unit id="pagination.last" resname="pagination.last"> + <source>last</source> + </trans-unit> + <trans-unit id="pagination.records" resname="pagination.records"> + <source>Records</source> + </trans-unit> + <trans-unit id="pagination.page" resname="pagination.page"> + <source>Page</source> + </trans-unit> + <trans-unit id="pagination.refresh" resname="pagination.refresh"> + <source>Refresh</source> + </trans-unit> + </body> + </file> +</xliff> diff --git a/typo3/sysext/reactions/Resources/Private/Partials/Pagination.html b/typo3/sysext/reactions/Resources/Private/Partials/Pagination.html new file mode 100644 index 0000000000000000000000000000000000000000..5cde722b4c3ffd3c6543fdcc3ba9874b9ebc3b55 --- /dev/null +++ b/typo3/sysext/reactions/Resources/Private/Partials/Pagination.html @@ -0,0 +1,96 @@ +<html xmlns:f="http://typo3.org/ns/TYPO3/CMS/Fluid/ViewHelpers" + xmlns:core="http://typo3.org/ns/TYPO3/CMS/Core/ViewHelpers" + data-namespace-typo3-fluid="true" +> +<f:if condition="{paginator.numberOfPages} > 1"> + <nav class="pagination-wrap"> + <ul class="pagination"> + <f:if condition="{pagination.previousPageNumber} && {pagination.previousPageNumber} >= {pagination.firstPageNumber}"> + <f:then> + <li class="page-item"> + <a href="{f:be.uri(route:'system_reactions', parameters: '{action: \'overview\', demand: demand.parameters, orderField: demand.orderField, orderDirection: demand.orderDirection, page: 1}')}" title="{f:translate(key:'LLL:EXT:reactions/Resources/Private/Language/locallang_module_reactions.xlf:pagination.first')}" class="page-link"> + <core:icon identifier="actions-view-paging-first" /> + </a> + </li> + <li class="page-item"> + <a href="{f:be.uri(route:'system_reactions', parameters: '{action: \'overview\', demand: demand.parameters, orderField: demand.orderField, orderDirection: demand.orderDirection, page: pagination.previousPageNumber}')}" title="{f:translate(key:'LLL:EXT:reactions/Resources/Private/Language/locallang_module_reactions.xlf:pagination.previous')}" class="page-link"> + <core:icon identifier="actions-view-paging-previous" /> + </a> + </li> + </f:then> + <f:else> + <li class="page-item disabled"> + <span class="page-link"> + <core:icon identifier="empty-empty"/> + </span> + </li> + <li class="page-item disabled"> + <span class="page-link"> + <core:icon identifier="empty-empty"/> + </span> + </li> + </f:else> + </f:if> + <li class="page-item"> + <span class="page-link"> + <f:translate key="LLL:EXT:reactions/Resources/Private/Language/locallang_module_reactions.xlf:pagination.records" /> {pagination.startRecordNumber} - {pagination.endRecordNumber} + </span> + </li> + <li class="page-item"> + <span class="page-link"> + <f:translate key="LLL:EXT:reactions/Resources/Private/Language/locallang_module_reactions.xlf:pagination.page" /> + <form style="display:inline;" + data-global-event="submit" + data-action-navigate="$form=~s/$value/" + data-navigate-value="{f:be.uri(route:'system_reactions', parameters: '{action: \'overview\', demand: demand.parameters, orderField: demand.orderField, orderDirection: demand.orderDirection, page: \'$[value]\'}')}" + data-value-selector="input[name='paginator-target-page']"> + <input + min="{pagination.firstPageNumber}" + max="{pagination.lastPageNumber}" + data-number-of-pages="{paginator.numberOfPages}" + name="paginator-target-page" + class="form-control form-control-sm paginator-input" + size="5" + value="{paginator.currentPageNumber}" + type="number" + /> + </form> + / {pagination.lastPageNumber} + </span> + </li> + + <f:if condition="{pagination.nextPageNumber} && {pagination.nextPageNumber} <= {pagination.lastPageNumber}"> + <f:then> + <li class="page-item"> + <a href="{f:be.uri(route:'system_reactions', parameters: '{action: \'overview\', demand: demand.parameters, orderField: demand.orderField, orderDirection: demand.orderDirection, page: pagination.nextPageNumber}')}" title="{f:translate(key:'LLL:EXT:reactions/Resources/Private/Language/locallang_module_reactions.xlf:pagination.next')}" class="page-link"> + <core:icon identifier="actions-view-paging-next" /> + </a> + </li> + <li class="page-item"> + <a href="{f:be.uri(route:'system_reactions', parameters: '{action: \'overview\', demand: demand.parameters, orderField: demand.orderField, orderDirection: demand.orderDirection, page: pagination.lastPageNumber}')}" title="{f:translate(key:'LLL:EXT:reactions/Resources/Private/Language/locallang_module_reactions.xlf:pagination.next')}" class="page-link"> + <core:icon identifier="actions-view-paging-last" /> + </a> + </li> + </f:then> + <f:else> + <li class="page-item disabled"> + <span class="page-link"> + <core:icon identifier="empty-empty"/> + </span> + </li> + <li class="page-item disabled"> + <span class="page-link"> + <core:icon identifier="empty-empty"/> + </span> + </li> + </f:else> + </f:if> + <li class="page-item"> + <a href="{f:be.uri(route:'system_reactions', parameters: '{action: \'overview\', demand: demand.parameters, orderField: demand.orderField, orderDirection: demand.orderDirection, page: pagination.currentPageNumber}')}" title="{f:translate(key:'LLL:EXT:reactions/Resources/Private/Language/locallang_module_reactions.xlf:pagination.refresh')}" class="page-link"> + <core:icon identifier="actions-refresh" /> + </a> + </li> + </ul> + </nav> +</f:if> +</html> diff --git a/typo3/sysext/reactions/Resources/Private/Templates/Management/Overview.html b/typo3/sysext/reactions/Resources/Private/Templates/Management/Overview.html new file mode 100644 index 0000000000000000000000000000000000000000..76f5479137649dc1ffdab1685707a83132f259c6 --- /dev/null +++ b/typo3/sysext/reactions/Resources/Private/Templates/Management/Overview.html @@ -0,0 +1,195 @@ +<html xmlns:f="http://typo3.org/ns/TYPO3/CMS/Fluid/ViewHelpers" + xmlns:be="http://typo3.org/ns/TYPO3/CMS/Backend/ViewHelpers" + xmlns:core="http://typo3.org/ns/TYPO3/CMS/Core/ViewHelpers" + data-namespace-typo3-fluid="true" +> +<f:layout name="Module" /> + +<f:section name="Content"> + <f:be.pageRenderer + includeJavaScriptModules="{ + 0: '@typo3/backend/modal.js' + }" + /> + <h1><f:translate key="LLL:EXT:reactions/Resources/Private/Language/locallang_module_reactions.xlf:heading_text"/></h1> + <f:variable + name="returnUrl" + value="{f:be.uri(route:'system_reactions')}" + /> + <f:if condition="{reactionRecords -> f:count()}"> + <f:then> + <f:render section="filter" arguments="{_all}" /> + <f:render section="table" arguments="{_all}" /> + </f:then> + <f:else> + <f:if condition="{demand.constraints}"> + <f:then> + <f:render section="filter" arguments="{_all}" /> + <f:be.infobox state="-2" title="{f:translate(key: 'LLL:EXT:reactions/Resources/Private/Language/locallang_module_reactions.xlf:reaction_not_found_with_filter.title')}"> + <p><f:translate key="LLL:EXT:reactions/Resources/Private/Language/locallang_module_reactions.xlf:reaction_not_found_with_filter.message"/></p> + <a class="btn btn-default" href="{f:be.uri(route:'system_reactions')}"> + <f:translate key="LLL:EXT:reactions/Resources/Private/Language/locallang_module_reactions.xlf:reaction_no_filter"/> + </a> + <be:link.newRecord returnUrl="{returnUrl}" class="btn btn-primary" table="sys_reaction"> + <f:translate key="LLL:EXT:reactions/Resources/Private/Language/locallang_module_reactions.xlf:reaction_create"/> + </be:link.newRecord> + </f:be.infobox> + + <f:variable name="gotToPageUrl"><f:be.uri route="system_reactions" parameters="{demand: demand.parameters, page: 987654322}" /></f:variable> + <form data-on-submit="processNavigate"> + <input type="hidden" value="1" name="paginator-target-page" data-number-of-pages="1" data-url="{gotToPageUrl}"/> + </form> + </f:then> + <f:else> + <f:be.infobox state="-1" title="{f:translate(key: 'LLL:EXT:reactions/Resources/Private/Language/locallang_module_reactions.xlf:reaction_not_found.title')}"> + <p><f:translate key="LLL:EXT:reactions/Resources/Private/Language/locallang_module_reactions.xlf:reaction_not_found.message"/></p> + <be:link.newRecord returnUrl="{returnUrl}" class="btn btn-primary" table="sys_reaction"> + <f:translate key="LLL:EXT:reactions/Resources/Private/Language/locallang_module_reactions.xlf:reaction_create"/> + </be:link.newRecord> + </f:be.infobox> + </f:else> + </f:if> + </f:else> + </f:if> +</f:section> + +<f:section name="table"> + <div class="table-fit"> + <table class="table table-striped table-hover"> + <thead> + <tr> + <th><f:render section="listHeaderSorting" arguments="{field: 'name', label: 'sys_reaction.name'}"/></th> + <th><f:render section="listHeaderSorting" arguments="{field: 'reaction', label: 'sys_reaction.reaction'}"/></th> + <th>URL</th> + <th></th> + </tr> + </thead> + <tbody> + <f:for each="{reactionRecords}" key="reactionId" as="reaction"> + <tr> + <td> + <be:link.editRecord + returnUrl="{returnUrl}" + table="sys_reaction" + uid="{reaction.uid}" + title="{f:translate(key: 'LLL:EXT:core/Resources/Private/Language/locallang_mod_web_list.xlf:edit')}: {reaction.title}" + > + {reaction.name} + </be:link.editRecord> + </td> + <td>{reactionTypes.{reaction.type}.description}</td> + <td> + <f:render section="codesnippet" arguments="{reaction: reaction}" /> + </td> + <td class="text-align-end"> + <div class="btn-group"> + <be:link.editRecord + returnUrl="{returnUrl}" + class="btn btn-default" + table="sys_reaction" + uid="{reaction.uid}" + title="{f:translate(key: 'LLL:EXT:core/Resources/Private/Language/locallang_mod_web_list.xlf:edit')}" + > + <core:icon identifier="actions-open" /> + </be:link.editRecord> + <f:if condition="{reaction.disabled} == 1"> + <f:then> + <a + class="btn btn-default" + href="{be:moduleLink(route:'tce_db', query:'data[sys_reaction][{reaction.uid}][disabled]=0', arguments:'{redirect: returnUrl}')}" + title="{f:translate(key:'LLL:EXT:core/Resources/Private/Language/locallang_mod_web_list.xlf:unHide')}" + > + <core:icon identifier="actions-edit-unhide" /> + </a> + </f:then> + <f:else> + <a + class="btn btn-default" + href="{be:moduleLink(route:'tce_db', query:'data[sys_reaction][{reaction.uid}][disabled]=1', arguments:'{redirect: returnUrl}')}" + title="{f:translate(key:'LLL:EXT:core/Resources/Private/Language/locallang_mod_web_list.xlf:hide')}" + > + <core:icon identifier="actions-edit-hide" /> + </a> + </f:else> + </f:if> + <a class="btn btn-default t3js-modal-trigger" + href="{be:moduleLink(route:'tce_db', query:'cmd[sys_reaction][{reaction.uid}][delete]=1', arguments:'{redirect: returnUrl}')}" + title="{f:translate(key: 'LLL:EXT:core/Resources/Private/Language/locallang_mod_web_list.xlf:delete')}" + data-severity="warning" + data-title="{f:translate(key: 'LLL:EXT:backend/Resources/Private/Language/locallang_alt_doc.xlf:label.confirm.delete_record.title')}" + data-bs-content="{f:translate(key: 'LLL:EXT:backend/Resources/Private/Language/locallang_alt_doc.xlf:deleteWarning')}" + data-button-close-text="{f:translate(key: 'LLL:EXT:backend/Resources/Private/Language/locallang_alt_doc.xlf:buttons.confirm.delete_record.no')}"> + <core:icon identifier="actions-delete" /> + </a> + </div> + </td> + </tr> + </f:for> + </tbody> + </table> + </div> + <f:render partial="Pagination" arguments="{_all}" /> +</f:section> + +<f:section name="listHeaderSorting"> + <f:if condition="{demand.orderField} === {field}"> + <f:then> + <a href="{f:be.uri(route:'system_reactions', parameters: '{demand: demand.parameters, orderField: field, orderDirection: demand.reverseOrderDirection}')}"> + <f:translate key="LLL:EXT:reactions/Resources/Private/Language/locallang_db.xlf:{label}"/> + </a> + <core:icon identifier="status-status-sorting-{demand.orderDirection}"/> + </f:then> + <f:else> + <a href="{f:be.uri(route:'system_reactions', parameters: '{demand: demand.parameters, orderField: field, orderDirection: demand.defaultOrderDirection}')}"> + <f:translate key="LLL:EXT:reactions/Resources/Private/Language/locallang_db.xlf:{label}"/> + </a> + </f:else> + </f:if> +</f:section> + +<f:section name="filter"> + <form action="{f:be.uri(route:'system_reactions')}" method="post" enctype="multipart/form-data" name="demand"> + <input type="hidden" name="orderField" value="{demand.orderField}"> + <input type="hidden" name="orderDirection" value="{demand.orderDirection}"> + <div class="row row-cols-auto align-items-end g-3 mb-4"> + <div class="col"> + <label for="demand-name" class="form-label"><f:translate key="LLL:EXT:reactions/Resources/Private/Language/locallang_db.xlf:sys_reaction.name"/></label> + <input type="text" id="demand-name" class="form-control" name="demand[name]" value="{demand.name}"/> + </div> + <div class="col"> + <label for="demand-reaction" class="form-label"><f:translate key="LLL:EXT:reactions/Resources/Private/Language/locallang_module_reactions.xlf:filter.reaction"/></label> + <select id="demand-reaction" class="form-select" name="demand[reaction]" data-on-change="submit"> + <option value=""><f:translate key="LLL:EXT:reactions/Resources/Private/Language/locallang_module_reactions.xlf:filter.reaction.showAll"/></option> + <f:for each="{reactionTypes}" key="type" as="reaction"> + <option value="{type}" {f:if(condition: '{type} == {demand.reaction}', then: 'selected')}> + <f:translate key="{reaction.description}" default="{reaction.description}"/> + </option> + </f:for> + </select> + </div> + <div class="col"> + <input type="submit" value="{f:translate(key: 'LLL:EXT:reactions/Resources/Private/Language/locallang_module_reactions.xlf:filter.sendButton')}" class="btn btn-default" /> + <a href="{f:be.uri(route:'system_reactions')}" class="btn btn-link"><f:translate key="LLL:EXT:reactions/Resources/Private/Language/locallang_module_reactions.xlf:filter.resetButton"/></a> + </div> + </div> + </form> +</f:section> + +<f:section name="codesnippet"> + <code class="my-1 p-2 me-2 bg-dark text-bg-primary"> + <f:be.uri route="reaction" referenceType="url" />/{reaction.identifier} + </code> + <button type="button" class="btn btn-default" data-bs-target="#codesnippet-{reaction.identifier}" data-bs-toggle="collapse"> + <f:translate key="LLL:EXT:reactions/Resources/Private/Language/locallang_module_reactions.xlf:reaction_example" /> + </button> + <div class="collapse" id="codesnippet-{reaction.identifier}"> +<pre class="my-1 p-2 bg-dark text-bg-primary"> +<code><span>curl -X </span><span class="text-info">'POST'</span><span> \</span><span> + </span><span class="text-info">'<f:be.uri route="reaction" referenceType="url" />/{reaction.identifier}'</span><span> \</span> + <span> -d </span><span class="text-info">'{"my-payload":"my-value","field-b":"my-input"}'</span><span> \</span> + <span> -H </span><span class="text-info">'accept: application/json'</span><span> \</span> + <span> -H </span><span class="text-info">'x-api-key: ***your-secret***'</span> +</code></pre> + </div> +</f:section> +</html> diff --git a/typo3/sysext/reactions/Resources/Public/Icons/Extension.svg b/typo3/sysext/reactions/Resources/Public/Icons/Extension.svg new file mode 100644 index 0000000000000000000000000000000000000000..c9116fa8ceeaaab98a1efd58ec2f55fab8e39c71 --- /dev/null +++ b/typo3/sysext/reactions/Resources/Public/Icons/Extension.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 64 64"><path fill="#666" d="M0 0h64v64H0z"/><path fill="#FFF" d="M48.9 19.4V19c0-.2 0-.3-.1-.5 0-.1-.1-.2-.1-.3 0-.1 0-.1-.1-.2s-.1-.2-.2-.3v-.1c-.4-.6-1.1-1.1-2-1.5-1.8-.8-4.5-1.1-7.3-1.2h-1.2c-.7 0-1.3 0-1.8.2-.2 0-.3.1-.5.2-.1.1-.3.2-.4.3-.1.1-.2.2-.3.4-.2.4-.3.9-.3 1.7 0 1.1.2 2.5.6 4 .2.7.4 1.4.7 2.2.5 1.3 1.1 2.7 1.7 3.9.2.3.3.6.5.9.2.3.3.6.5.8.4.6.8 1.1 1.2 1.6.9 1 1.9 1.7 2.8 1.7 1.5 0 3.8-3.7 5.1-8.1.8-1.8 1.1-3.6 1.2-5.3zM37.1 17l-.4.4V17h.4zm-.1 2.9 2.9-2.9c1 0 1.8.1 2.6.2l-5 5c-.2-.8-.4-1.5-.5-2.3zm2.1 6.2c-.3-.6-.6-1.2-.8-1.8l6.6-6.6c1.1.3 1.6.7 1.9.9l-7.6 7.6s0-.1-.1-.1zm1.1 1.9 6.4-6.4c-.2 1.4-.6 2.7-1.1 3.9l-4.1 4.1c-.3-.4-.7-.9-1.2-1.6zM39.2 38.6c-.1 0-.3 0-.4-.1-.1 0-.3-.1-.4-.2-.5-.2-1.1-.6-1.6-1.2-.2-.2-.4-.4-.6-.7 0 0 0-.1-.1-.1-.2-.2-.4-.5-.6-.8-.4-.5-.8-1.1-1.1-1.7-.4-.6-.7-1.2-1-1.8-.3-.6-.6-1.2-1-1.9-.3-.6-.6-1.3-.9-1.9-.3-.7-.6-1.3-.8-2-.3-.7-.5-1.4-.7-2.1-.2-.8-.5-1.5-.7-2.2l-.3-1.2c-.1-.4-.1-.8-.2-1.1-.1-.5-.1-1-.1-1.4 0-.6 0-1.1.1-1.5.1-.4.2-.7.3-.9.1-.1.1-.2.2-.3.1-.1.2-.1.3-.2-.4 0-.8.1-1.3.2h-.2c-.4.1-.8.1-1.2.2-1.6.3-3.3.7-4.8 1.2H22c-.4.1-.7.2-1 .4h-.1c-2.2.8-4 1.8-4.9 3-.3.5-.6 1.2-.7 2v.8c0 1.7.4 3.8 1 6.1.9 3.2 2.4 6.8 4.1 10 .3.6.7 1.2 1 1.8 1.1 1.9 2.3 3.6 3.5 4.9.2.3.5.5.7.7.2.2.5.4.7.6.8.7 1.6 1.1 2.4 1.3h.1c.2 0 .5.1.7.1.2 0 .4 0 .7-.1h.1c.2-.1.5-.1.7-.2 2.5-1 5.8-4.4 8.8-8.8.3-.5.6-1 .9-1.4-.6.4-1 .5-1.5.5zm-11-12.7-8.3 8.3c-.3-.7-.6-1.4-.8-2l8.4-8.4c.2.6.5 1.4.7 2.1zm-11.1-2.6v-.5l2.7-2.7c1.2-.6 2.7-1.1 4.4-1.5l-6.9 6.9c-.1-.8-.2-1.5-.2-2.2zm9.4-4.2c.1.7.2 1.5.4 2.4L18.4 30c-.2-.8-.4-1.5-.6-2.2l8.7-8.7zm-5.7 17L29 28c.3.7.5 1.3.8 2l-8.1 8c-.3-.6-.6-1.2-.9-1.9zm9.9-4.2c.3.6.6 1.3 1 1.9l-7.9 7.9c-.4-.5-.7-1.1-1.1-1.8l8-8zm-5.8 11.4 7.8-7.8c.4.6.8 1.1 1.2 1.7l-7.7 7.7c-.4-.5-.8-1-1.3-1.6zm10.4-4.7c.5.5 1 .9 1.6 1.3L29.7 47h-.3c-.4 0-1-.3-1.7-.8l7.6-7.6z"/></svg> \ No newline at end of file diff --git a/typo3/sysext/reactions/Resources/Public/Icons/mimetypes-x-sys_reaction.svg b/typo3/sysext/reactions/Resources/Public/Icons/mimetypes-x-sys_reaction.svg new file mode 100644 index 0000000000000000000000000000000000000000..c9116fa8ceeaaab98a1efd58ec2f55fab8e39c71 --- /dev/null +++ b/typo3/sysext/reactions/Resources/Public/Icons/mimetypes-x-sys_reaction.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 64 64"><path fill="#666" d="M0 0h64v64H0z"/><path fill="#FFF" d="M48.9 19.4V19c0-.2 0-.3-.1-.5 0-.1-.1-.2-.1-.3 0-.1 0-.1-.1-.2s-.1-.2-.2-.3v-.1c-.4-.6-1.1-1.1-2-1.5-1.8-.8-4.5-1.1-7.3-1.2h-1.2c-.7 0-1.3 0-1.8.2-.2 0-.3.1-.5.2-.1.1-.3.2-.4.3-.1.1-.2.2-.3.4-.2.4-.3.9-.3 1.7 0 1.1.2 2.5.6 4 .2.7.4 1.4.7 2.2.5 1.3 1.1 2.7 1.7 3.9.2.3.3.6.5.9.2.3.3.6.5.8.4.6.8 1.1 1.2 1.6.9 1 1.9 1.7 2.8 1.7 1.5 0 3.8-3.7 5.1-8.1.8-1.8 1.1-3.6 1.2-5.3zM37.1 17l-.4.4V17h.4zm-.1 2.9 2.9-2.9c1 0 1.8.1 2.6.2l-5 5c-.2-.8-.4-1.5-.5-2.3zm2.1 6.2c-.3-.6-.6-1.2-.8-1.8l6.6-6.6c1.1.3 1.6.7 1.9.9l-7.6 7.6s0-.1-.1-.1zm1.1 1.9 6.4-6.4c-.2 1.4-.6 2.7-1.1 3.9l-4.1 4.1c-.3-.4-.7-.9-1.2-1.6zM39.2 38.6c-.1 0-.3 0-.4-.1-.1 0-.3-.1-.4-.2-.5-.2-1.1-.6-1.6-1.2-.2-.2-.4-.4-.6-.7 0 0 0-.1-.1-.1-.2-.2-.4-.5-.6-.8-.4-.5-.8-1.1-1.1-1.7-.4-.6-.7-1.2-1-1.8-.3-.6-.6-1.2-1-1.9-.3-.6-.6-1.3-.9-1.9-.3-.7-.6-1.3-.8-2-.3-.7-.5-1.4-.7-2.1-.2-.8-.5-1.5-.7-2.2l-.3-1.2c-.1-.4-.1-.8-.2-1.1-.1-.5-.1-1-.1-1.4 0-.6 0-1.1.1-1.5.1-.4.2-.7.3-.9.1-.1.1-.2.2-.3.1-.1.2-.1.3-.2-.4 0-.8.1-1.3.2h-.2c-.4.1-.8.1-1.2.2-1.6.3-3.3.7-4.8 1.2H22c-.4.1-.7.2-1 .4h-.1c-2.2.8-4 1.8-4.9 3-.3.5-.6 1.2-.7 2v.8c0 1.7.4 3.8 1 6.1.9 3.2 2.4 6.8 4.1 10 .3.6.7 1.2 1 1.8 1.1 1.9 2.3 3.6 3.5 4.9.2.3.5.5.7.7.2.2.5.4.7.6.8.7 1.6 1.1 2.4 1.3h.1c.2 0 .5.1.7.1.2 0 .4 0 .7-.1h.1c.2-.1.5-.1.7-.2 2.5-1 5.8-4.4 8.8-8.8.3-.5.6-1 .9-1.4-.6.4-1 .5-1.5.5zm-11-12.7-8.3 8.3c-.3-.7-.6-1.4-.8-2l8.4-8.4c.2.6.5 1.4.7 2.1zm-11.1-2.6v-.5l2.7-2.7c1.2-.6 2.7-1.1 4.4-1.5l-6.9 6.9c-.1-.8-.2-1.5-.2-2.2zm9.4-4.2c.1.7.2 1.5.4 2.4L18.4 30c-.2-.8-.4-1.5-.6-2.2l8.7-8.7zm-5.7 17L29 28c.3.7.5 1.3.8 2l-8.1 8c-.3-.6-.6-1.2-.9-1.9zm9.9-4.2c.3.6.6 1.3 1 1.9l-7.9 7.9c-.4-.5-.7-1.1-1.1-1.8l8-8zm-5.8 11.4 7.8-7.8c.4.6.8 1.1 1.2 1.7l-7.7 7.7c-.4-.5-.8-1-1.3-1.6zm10.4-4.7c.5.5 1 .9 1.6 1.3L29.7 47h-.3c-.4 0-1-.3-1.7-.8l7.6-7.6z"/></svg> \ No newline at end of file diff --git a/typo3/sysext/reactions/Tests/Functional/Fixtures/ReactionsRepositoryTest_pages.csv b/typo3/sysext/reactions/Tests/Functional/Fixtures/ReactionsRepositoryTest_pages.csv new file mode 100644 index 0000000000000000000000000000000000000000..1b354445dc1b14d97e1db81c7f492ada49f8261c --- /dev/null +++ b/typo3/sysext/reactions/Tests/Functional/Fixtures/ReactionsRepositoryTest_pages.csv @@ -0,0 +1,3 @@ +pages,,,,,,,,,,, +,uid,pid,crdate,deleted,hidden,title +,1,0,2147483647,0,0,Test Parent diff --git a/typo3/sysext/reactions/Tests/Functional/Fixtures/ReactionsRepositoryTest_reactions.csv b/typo3/sysext/reactions/Tests/Functional/Fixtures/ReactionsRepositoryTest_reactions.csv new file mode 100644 index 0000000000000000000000000000000000000000..33d5f265bf7a1b872973f56e56c69ab106c7138f --- /dev/null +++ b/typo3/sysext/reactions/Tests/Functional/Fixtures/ReactionsRepositoryTest_reactions.csv @@ -0,0 +1,4 @@ +sys_reaction,,,,,,,,,,,,, +,uid,pid,createdon,deleted,disabled,name,secret,reactiontype,table_name,fields,storage_pid,identifier,impersonate_user +,1,0,1,0,0,Test Visual,secret1,create-record,pages,{"title":"Test ${foo}"},1,"visual-reaction-uuid",1 +,2,0,1,0,0,Random Test,anysecret,foo-reaction,pages,{"title":"Test ${foo}"},1,"foo-uuid",1 diff --git a/typo3/sysext/reactions/Tests/Functional/Reaction/CreateRecordReactionTest.php b/typo3/sysext/reactions/Tests/Functional/Reaction/CreateRecordReactionTest.php new file mode 100644 index 0000000000000000000000000000000000000000..e7b362c6d62ae95653bbe5d79d8704d4c854cad5 --- /dev/null +++ b/typo3/sysext/reactions/Tests/Functional/Reaction/CreateRecordReactionTest.php @@ -0,0 +1,279 @@ +<?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\Reactions\Tests\Functional\Reaction; + +use Psr\Http\Message\ServerRequestInterface; +use TYPO3\CMS\Core\Authentication\BackendUserAuthentication; +use TYPO3\CMS\Core\Database\ConnectionPool; +use TYPO3\CMS\Core\Database\Query\Restriction\DeletedRestriction; +use TYPO3\CMS\Core\Http\ServerRequest; +use TYPO3\CMS\Core\Localization\LanguageServiceFactory; +use TYPO3\CMS\Core\Utility\GeneralUtility; +use TYPO3\CMS\Reactions\Authentication\ReactionUserAuthentication; +use TYPO3\CMS\Reactions\Model\ReactionInstruction; +use TYPO3\CMS\Reactions\Reaction\CreateRecordReaction; +use TYPO3\CMS\Reactions\Repository\ReactionRepository; +use TYPO3\TestingFramework\Core\Functional\FunctionalTestCase; + +class CreateRecordReactionTest extends FunctionalTestCase +{ + protected array $coreExtensionsToLoad = ['reactions']; + + /** + * @test + */ + public function reactWorksForAValidRequest(): void + { + $GLOBALS['LANG'] = GeneralUtility::makeInstance(LanguageServiceFactory::class) + ->create('default'); + + $this->importCSVDataSet(__DIR__ . '/../../../../core/Tests/Functional/Fixtures/be_users.csv'); + $this->importCSVDataSet(__DIR__ . '/../Fixtures/ReactionsRepositoryTest_pages.csv'); + $this->importCSVDataSet(__DIR__ . '/../Fixtures/ReactionsRepositoryTest_reactions.csv'); + $reactionRecord = (new ReactionRepository())->getReactionRecordByIdentifier('visual-reaction-uuid'); + $reaction = GeneralUtility::makeInstance(CreateRecordReaction::class); + $request = new ServerRequest('http://localhost/', 'POST'); + $payload = [ + 'foo' => 'bar', + 'bar' => [ + 'string' => 'bar.foo', + 'int' => 42, + 'bool' => true, + ], + ]; + $user = $this->setUpReactionBackendUser($request, $reactionRecord); + $request = $request->withHeader('x-api-key', $reactionRecord->toArray()['secret']); + $request = $request->withAttribute('backend.user', $user); + + self::assertCount(0, $this->getTestPages()); + + $response = $reaction->react($request, $payload, $reactionRecord); + + self::assertEquals(201, $response->getStatusCode()); + self::assertCount(1, $this->getTestPages()); + } + + protected function getTestPages(): array + { + $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class) + ->getQueryBuilderForTable('pages'); + $queryBuilder->getRestrictions()->removeAll(); + $queryBuilder->getRestrictions()->add(GeneralUtility::makeInstance(DeletedRestriction::class)); + return $queryBuilder->select('*') + ->from('pages') + ->where( + $queryBuilder->expr()->eq('title', $queryBuilder->createNamedParameter('Test bar')), + $queryBuilder->expr()->eq('pid', $queryBuilder->createNamedParameter(1)) + ) + ->executeQuery() + ->fetchAllAssociative(); + } + + protected function setUpReactionBackendUser(ServerRequestInterface $request, ReactionInstruction $reactionInstruction): BackendUserAuthentication + { + $backendUser = GeneralUtility::makeInstance(ReactionUserAuthentication::class); + /** @var ReactionUserAuthentication $backendUser */ + $backendUser->setReactionInstruction($reactionInstruction); + return $this->authenticateBackendUser($backendUser, $request); + } + + public function replacePlaceHolderDataProvider(): array + { + return [ + 'no placeholders' => [ + 'value' => 'foo', + 'payload' => [], + 'expected' => 'foo', + ], + 'placeholder in value' => [ + 'value' => '${foo}', + 'payload' => [ + 'foo' => 'bar', + ], + 'expected' => 'bar', + ], + 'placeholder in value is integer' => [ + 'value' => '${foo}', + 'payload' => [ + 'foo' => 42, + ], + 'expected' => '42', + ], + 'placeholder in value is float' => [ + 'value' => '${foo}', + 'payload' => [ + 'foo' => 42.5, + ], + 'expected' => '42.5', + ], + 'placeholder in value is boolean true' => [ + 'value' => '${foo}', + 'payload' => [ + 'foo' => true, + ], + 'expected' => '1', + ], + 'placeholder in value is boolean false' => [ + 'value' => '${foo}', + 'payload' => [ + 'foo' => false, + ], + 'expected' => '', + ], + 'two placeholder in value' => [ + 'value' => '${foo} ${bar}', + 'payload' => [ + 'foo' => 'bar', + 'bar' => 'foo', + ], + 'expected' => 'bar foo', + ], + 'placeholder in value with dot' => [ + 'value' => '${foo.bar}', + 'payload' => [ + 'foo' => [ + 'bar' => 'baz', + ], + ], + 'expected' => 'baz', + ], + 'placeholder in value with dot and array access' => [ + 'value' => '${foo.bar.0}', + 'payload' => [ + 'foo' => [ + 'bar' => [ + '0' => 'baz', + ], + ], + ], + 'expected' => 'baz', + ], + 'placeholder in value with dot and numeric array access' => [ + 'value' => '${foo.bar.0}', + 'payload' => [ + 'foo' => [ + 'bar' => [ + 'baz', + ], + ], + ], + 'expected' => 'baz', + ], + 'placeholder in value with dot and array access and array access in value' => [ + 'value' => '${foo.bar.0.baz}', + 'payload' => [ + 'foo' => [ + 'bar' => [ + '0' => [ + 'baz' => 'qux', + ], + ], + ], + ], + 'expected' => 'qux', + ], + 'placeholder in value with dot and array access and numeric array access in value' => [ + 'value' => '${foo.bar.0.baz}', + 'payload' => [ + 'foo' => [ + 'bar' => [ + [ + 'baz' => 'qux', + ], + ], + ], + ], + 'expected' => 'qux', + ], + 'placeholder in value with dot and array access and array access in value and array access in value' => [ + 'value' => '${foo.bar.0.baz.0}', + 'payload' => [ + 'foo' => [ + 'bar' => [ + '0' => [ + 'baz' => [ + '0' => 'qux', + ], + ], + ], + ], + ], + 'expected' => 'qux', + ], + 'placeholder in value with dot and array numeric access and numeric array access in value and numeric array access in value' => [ + 'value' => '${foo.bar.0.baz.0}', + 'payload' => [ + 'foo' => [ + 'bar' => [ + [ + 'baz' => [ + 'qux', + ], + ], + ], + ], + ], + 'expected' => 'qux', + ], + 'placeholder in value with dot and array access and array access in value and array access in value and array access in value' => [ + 'value' => '${foo.bar.0.baz.0.qux}', + 'payload' => [ + 'foo' => [ + 'bar' => [ + '0' => [ + 'baz' => [ + '0' => [ + 'qux' => 'quux', + ], + ], + ], + ], + ], + ], + 'expected' => 'quux', + ], + 'placeholder in value with dot and array access and numeric array access in value and numeric array access in value and numeric array access in value' => [ + 'value' => '${foo.bar.0.baz.0.qux}', + 'payload' => [ + 'foo' => [ + 'bar' => [ + [ + 'baz' => [ + [ + 'qux' => 'quux', + ], + ], + ], + ], + ], + ], + 'expected' => 'quux', + ], + ]; + } + + /** + * @dataProvider replacePlaceHolderDataProvider + * @test + */ + public function replacePlaceHolders(mixed $value, array $payload, string $expected): void + { + $subject = GeneralUtility::makeInstance(CreateRecordReaction::class); + self::assertSame($expected, $subject->replacePlaceHolders($value, $payload)); + } +} diff --git a/typo3/sysext/reactions/Tests/Functional/ReactionRegistryTest.php b/typo3/sysext/reactions/Tests/Functional/ReactionRegistryTest.php new file mode 100644 index 0000000000000000000000000000000000000000..9b013f013d1ae3f5ef47864db1a3e3322f7e13f2 --- /dev/null +++ b/typo3/sysext/reactions/Tests/Functional/ReactionRegistryTest.php @@ -0,0 +1,70 @@ +<?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\Reactions\Tests\Functional; + +use TYPO3\CMS\Core\Utility\GeneralUtility; +use TYPO3\CMS\Reactions\Reaction\CreateRecordReaction; +use TYPO3\CMS\Reactions\ReactionRegistry; +use TYPO3\TestingFramework\Core\Functional\FunctionalTestCase; + +class ReactionRegistryTest extends FunctionalTestCase +{ + protected bool $resetSingletonInstances = true; + + protected array $coreExtensionsToLoad = ['reactions']; + + protected ReactionRegistry $subject; + + protected function setUp(): void + { + parent::setUp(); + $reactions = $this->buildReactionMock(); + $this->subject = new ReactionRegistry($reactions); + } + + /** + * @test + */ + public function getAvailableReactionTypes(): void + { + $types = iterator_to_array($this->subject->getAvailableReactionTypes()->getIterator()); + self::assertInstanceOf(CreateRecordReaction::class, reset($types)); + self::assertCount(1, $types); + } + + /** + * @test + */ + public function getReactionByType(): void + { + self::assertInstanceOf(CreateRecordReaction::class, $this->subject->getReactionByType(CreateRecordReaction::getType())); + self::assertNull($this->subject->getReactionByType('invalid')); + } + + protected function buildReactionMock(): \IteratorAggregate + { + $class = new class () implements \IteratorAggregate { + public function getIterator(): \Traversable + { + return new \ArrayIterator([CreateRecordReaction::getType() => GeneralUtility::makeInstance(CreateRecordReaction::class)]); + } + }; + + return new $class(); + } +} diff --git a/typo3/sysext/reactions/Tests/Functional/Repository/ReactionsRepositoryTest.php b/typo3/sysext/reactions/Tests/Functional/Repository/ReactionsRepositoryTest.php new file mode 100644 index 0000000000000000000000000000000000000000..3366aac251fb5909d6a278f0edd550e35fd5e4dd --- /dev/null +++ b/typo3/sysext/reactions/Tests/Functional/Repository/ReactionsRepositoryTest.php @@ -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\Reactions\Tests\Functional\Repository; + +use TYPO3\CMS\Reactions\Repository\ReactionDemand; +use TYPO3\CMS\Reactions\Repository\ReactionRepository; +use TYPO3\TestingFramework\Core\Functional\FunctionalTestCase; + +class ReactionsRepositoryTest extends FunctionalTestCase +{ + protected array $coreExtensionsToLoad = ['reactions']; + + public function demandProvider(): array + { + return [ + 'default demand' => [$this->getDemand(), 2], + 'filter by name: Test' => [$this->getDemand('Test'), 2], + 'filter by name: Random' => [$this->getDemand('Random'), 1], + 'filter by reaction: CreateRecordReaction' => [$this->getDemand('', 'create-record'), 1], + 'filter by name and reaction: CreateRecordReaction' => [$this->getDemand('Test', 'create-record'), 1], + ]; + } + + /** + * @dataProvider demandProvider + * @test + * @param ReactionDemand $demand + * @param int $resultCount + */ + public function findByDemandWorks( + ReactionDemand $demand, + int $resultCount, + ): void { + $this->importCSVDataSet(__DIR__ . '/../Fixtures/ReactionsRepositoryTest_reactions.csv'); + $results = (new ReactionRepository())->findByDemand($demand); + self::assertCount($resultCount, $results); + } + + /** + * @test + */ + public function findAllWorks(): void + { + $this->importCSVDataSet(__DIR__ . '/../Fixtures/ReactionsRepositoryTest_reactions.csv'); + $results = (new ReactionRepository())->findAll(); + self::assertCount(2, $results); + } + + /** + * @test + */ + public function getReactionRecordsWithoutDemand(): void + { + $this->importCSVDataSet(__DIR__ . '/../Fixtures/ReactionsRepositoryTest_reactions.csv'); + $reactions = (new ReactionRepository())->getReactionRecords(); + self::assertCount(2, $reactions); + } + + private function getDemand( + string $name = '', + string $reaction = '' + ): ReactionDemand { + return new ReactionDemand( + 1, + '', + '', + $name, + $reaction, + ); + } +} diff --git a/typo3/sysext/reactions/composer.json b/typo3/sysext/reactions/composer.json new file mode 100644 index 0000000000000000000000000000000000000000..3a4f8a4a8350dd4bd6af68a1fe26b709d61f4191 --- /dev/null +++ b/typo3/sysext/reactions/composer.json @@ -0,0 +1,41 @@ +{ + "name": "typo3/cms-reactions", + "type": "typo3-cms-framework", + "description": "TYPO3 CMS Reactions - Handle incoming Webhooks for TYPO3", + "homepage": "https://typo3.org", + "license": ["GPL-2.0-or-later"], + "authors": [{ + "name": "TYPO3 Core Team", + "email": "typo3cms@typo3.org", + "role": "Developer" + }], + "support": { + "chat": "https://typo3.org/help", + "docs": "https://docs.typo3.org", + "issues": "https://forge.typo3.org", + "source": "https://github.com/typo3/typo3" + }, + "config": { + "sort-packages": true + }, + "require": { + "symfony/uid": "^6.2", + "typo3/cms-core": "12.1.*@dev" + }, + "conflict": { + "typo3/cms": "*" + }, + "extra": { + "branch-alias": { + "dev-main": "12.1.x-dev" + }, + "typo3/cms": { + "extension-key": "reactions" + } + }, + "autoload": { + "psr-4": { + "TYPO3\\CMS\\Reactions\\": "Classes/" + } + } +} diff --git a/typo3/sysext/reactions/ext_emconf.php b/typo3/sysext/reactions/ext_emconf.php new file mode 100644 index 0000000000000000000000000000000000000000..0ca226a32e10fed71959749a25cd096aca2408ea --- /dev/null +++ b/typo3/sysext/reactions/ext_emconf.php @@ -0,0 +1,19 @@ +<?php + +$EM_CONF[$_EXTKEY] = [ + 'title' => 'TYPO3 CMS Reactions', + 'description' => 'Handle incoming Webhooks for TYPO3', + 'category' => 'module', + 'author' => 'TYPO3 Core Team', + 'author_email' => 'typo3cms@typo3.org', + 'state' => 'stable', + 'author_company' => '', + 'version' => '12.1.0', + 'constraints' => [ + 'depends' => [ + 'typo3' => '12.1.0', + ], + 'conflicts' => [], + 'suggests' => [], + ], +]; diff --git a/typo3/sysext/reactions/ext_localconf.php b/typo3/sysext/reactions/ext_localconf.php new file mode 100644 index 0000000000000000000000000000000000000000..b25163e51c8b97418f2ee2847733838dae81287f --- /dev/null +++ b/typo3/sysext/reactions/ext_localconf.php @@ -0,0 +1,24 @@ +<?php + +declare(strict_types=1); + +use TYPO3\CMS\Reactions\Form\Element\FieldMapElement; +use TYPO3\CMS\Reactions\Form\Element\UuidElement; +use TYPO3\CMS\Reactions\Hooks\DataHandlerHook; + +defined('TYPO3') or die(); + +$GLOBALS['TYPO3_CONF_VARS']['SYS']['formEngine']['nodeRegistry'][1660911089] = [ + 'nodeName' => 'fieldMap', + 'priority' => 40, + 'class' => FieldMapElement::class, +]; + +// @todo This should be a dedicated TCA type instead +$GLOBALS['TYPO3_CONF_VARS']['SYS']['formEngine']['nodeRegistry'][1660911009] = [ + 'nodeName' => 'uuid', + 'priority' => 40, + 'class' => UuidElement::class, +]; + +$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_tcemain.php']['processDatamapClass'][] = DataHandlerHook::class; diff --git a/typo3/sysext/reactions/ext_tables.sql b/typo3/sysext/reactions/ext_tables.sql new file mode 100644 index 0000000000000000000000000000000000000000..63d56945bc7f4681b7c845d93af72f6359ec6f66 --- /dev/null +++ b/typo3/sysext/reactions/ext_tables.sql @@ -0,0 +1,16 @@ +# +# Table structure for table 'sys_reaction' +# +CREATE TABLE sys_reaction ( + name varchar(100) DEFAULT '' NOT NULL, + reactiontype varchar(255) DEFAULT '' NOT NULL, + identifier varchar(36) DEFAULT '' NOT NULL, + secret varchar(255) DEFAULT '' NOT NULL, + impersonate_user int(11) unsigned DEFAULT '0' NOT NULL, + table_name varchar(255) DEFAULT '' NOT NULL, + storage_pid int(11) unsigned DEFAULT '0' NOT NULL, + fields json NOT NULL, + + UNIQUE identifier_key (identifier), + KEY index_source (reactiontype(5)) +);