diff --git a/README.md b/README.md index 45d6d38..6142fb8 100644 --- a/README.md +++ b/README.md @@ -40,4 +40,4 @@ codefog_tags: ## Insert Tags -The extension also provides two new insert tags: `{{insert_node::*}}` and `{{insert_nodes::*}}`. The former expects the ID of a node and will then generate the output of that node. The latter expects a comma separated list of node IDs and will then generate the output of all those nodes. Example: `{{insert_nodes::1,2,3}}` +The extension also provides two new insert tags: `{{insert_node::*}}` and `{{insert_nodes::*}}`. The former expects the ID of a node and will then generate the output of that node. The latter expects a comma separated list of node IDs or aliases and will then generate the output of all those nodes. Example: `{{insert_nodes::1,2,3}}` diff --git a/composer-dependency-analyser.php b/composer-dependency-analyser.php index dc4dda8..7746b2c 100644 --- a/composer-dependency-analyser.php +++ b/composer-dependency-analyser.php @@ -6,6 +6,4 @@ return (new Configuration()) // Optional integrations ->ignoreErrorsOnPackage('terminal42/contao-geoip2-country', [ErrorType::DEV_DEPENDENCY_IN_PROD]) - - ->ignoreUnknownClasses(['Haste\Model\Model']) ; diff --git a/composer.json b/composer.json index 6e4a82e..3773df6 100755 --- a/composer.json +++ b/composer.json @@ -25,17 +25,21 @@ "source": "https://github.com/terminal42/contao-node" }, "require": { - "php": "^8.0", - "contao/core-bundle": "^4.13 || ^5.0", - "codefog/contao-haste": "^4.21 || ^5.0", + "php": "^8.1", + "contao/core-bundle": "^5.3", + "codefog/contao-haste": "^5.2", "codefog/tags-bundle": "^3.3", - "doctrine/dbal": "^3.3 || ^4.0", + "doctrine/dbal": "^3.3 || ^4.3", "psr/log": "^1.0 || ^2.0 || ^3.0", "symfony/config": "^5.0 || ^6.0 || ^7.0", "symfony/dependency-injection": "^5.0 || ^6.0 || ^7.0", "symfony/http-foundation": "^5.0 || ^6.0 || ^7.0", "symfony/http-kernel": "^5.0 || ^6.0 || ^7.0", - "symfony/security-core": "^5.0 || ^6.0 || ^7.0" + "symfony/security-core": "^5.0 || ^6.0 || ^7.0", + "symfony/security-bundle": "^5.0 || ^6.0 || ^7.0", + "symfony/service-contracts": "^3.0", + "symfony/translation-contracts": "^3.0", + "twig/twig": "^3.0" }, "require-dev": { "terminal42/contao-geoip2-country": "^1.4", diff --git a/config/controllers.php b/config/controllers.php new file mode 100755 index 0000000..d734ab4 --- /dev/null +++ b/config/controllers.php @@ -0,0 +1,24 @@ +services(); + $services->defaults()->autoconfigure(); + + $services + ->set(NodesContentElementController::class) + ->arg('$nodeManager', service(NodeManager::class)) + ; + + $services + ->set(NodesFrontendModuleController::class) + ->arg('$nodeManager', service(NodeManager::class)) + ; +}; diff --git a/config/listeners.php b/config/listeners.php new file mode 100755 index 0000000..5685c02 --- /dev/null +++ b/config/listeners.php @@ -0,0 +1,30 @@ +services(); + $services->defaults()->autoconfigure(); + + $services + ->set(DataContainerListener::class) + ->arg('$connection', service('database_connection')) + ->arg('$finderFactory', service('contao.twig.finder_factory')) + ->arg('$locales', service('contao.intl.locales')) + ->arg('$logger', service('monolog.logger.contao')) + ->arg('$requestStack', service('request_stack')) + ->arg('$security', service('security.helper')) + ->arg('$tagsManager', service('codefog_tags.manager.terminal42_node')) + ->arg('$translator', service('translator')) + ; + + $services + ->set(ContentListener::class) + ->arg('$connection', service('database_connection')) + ; +}; diff --git a/config/migrations.php b/config/migrations.php new file mode 100755 index 0000000..1012283 --- /dev/null +++ b/config/migrations.php @@ -0,0 +1,17 @@ +services(); + $services->defaults()->autoconfigure(); + + $services + ->set(GuestsMigration::class) + ->arg('$connection', service('database_connection')) + ; +}; diff --git a/config/security.php b/config/security.php new file mode 100755 index 0000000..c364b63 --- /dev/null +++ b/config/security.php @@ -0,0 +1,33 @@ +services(); + $services->defaults()->autoconfigure(); + + $services + ->set(BackendAccessVoter::class) + ->decorate('contao.security.backend_access_voter') + ->arg('$contaoFramework', service('contao.framework')) + ->arg('$inner', service('.inner')) + ; + + $services + ->set(NodeContentVoter::class) + ->arg('$accessDecisionManager', service('security.access.decision_manager')) + ->arg('$connection', service('database_connection')) + ; + + $services + ->set(NodePermissionVoter::class) + ->arg('$accessDecisionManager', service('security.access.decision_manager')) + ->arg('$contaoFramework', service('contao.framework')) + ; +}; diff --git a/config/services.php b/config/services.php new file mode 100755 index 0000000..7b4ba38 --- /dev/null +++ b/config/services.php @@ -0,0 +1,32 @@ +services(); + $services->defaults()->autoconfigure(); + + $services + ->set(NodeInsertTag::class) + ->arg('$manager', service(NodeManager::class)) + ->arg('$logger', service('monolog.logger.contao')) + ; + + $services + ->set(NodeManager::class) + ->arg('$twig', service('twig')) + ; + + $services + ->set(NodePickerProvider::class) + ->arg('$menuFactory', service('knp_menu.factory')) + ->arg('$router', service('router')) + ->arg('$translator', service('translator')) + ; +}; diff --git a/config/services.yml b/config/services.yml deleted file mode 100755 index c48a0cd..0000000 --- a/config/services.yml +++ /dev/null @@ -1,49 +0,0 @@ -services: - Terminal42\NodeBundle\NodeManager: '@terminal42_node.manager' - - terminal42_node.manager: - class: Terminal42\NodeBundle\NodeManager - public: true - - terminal42_node.listener.content: - class: Terminal42\NodeBundle\EventListener\ContentListener - public: true - arguments: - - "@database_connection" - - "@terminal42_node.permission_checker" - - terminal42_node.listener.data_container: - class: Terminal42\NodeBundle\EventListener\DataContainerListener - public: true - arguments: - - "@database_connection" - - "@contao.intl.locales" - - "@monolog.logger.contao" - - "@terminal42_node.permission_checker" - - "@request_stack" - - "@codefog_tags.manager.terminal42_node" - - terminal42_node.listener.insert_tags: - class: Terminal42\NodeBundle\EventListener\InsertTagsListener - public: true - arguments: - - "@terminal42_node.manager" - - "@?logger" - - terminal42_node.permission_checker: - class: Terminal42\NodeBundle\PermissionChecker - public: false - arguments: - - "@database_connection" - - "@security.authorization_checker" - - "@security.token_storage" - - terminal42_node.picker: - class: Terminal42\NodeBundle\Picker\NodePickerProvider - public: false - arguments: - - "@knp_menu.factory" - - "@router" - - "@translator" - tags: - - { name: contao.picker_provider, priority: 132 } diff --git a/contao/config/config.php b/contao/config/config.php index 7d4483a..187a8c1 100755 --- a/contao/config/config.php +++ b/contao/config/config.php @@ -1,47 +1,21 @@ array_values(array_unique(array_merge(['tl_node', 'tl_content'], $GLOBALS['BE_MOD']['content']['article']['tables'] ?? []))), 'table' => &$GLOBALS['BE_MOD']['content']['article']['table'], 'list' => &$GLOBALS['BE_MOD']['content']['article']['list'], ]; -/* - * Back end form fields - */ +// Back end form fields $GLOBALS['BE_FFL']['nodePicker'] = NodePickerWidget::class; -/* - * Frontend modules - */ -$GLOBALS['FE_MOD']['miscellaneous']['nodes'] = NodesModule::class; - -/* - * Content elements - */ -$GLOBALS['TL_CTE']['includes']['nodes'] = NodesContentElement::class; - -/* - * Models - */ +// Models $GLOBALS['TL_MODELS']['tl_node'] = NodeModel::class; -/* - * Hooks - */ -$GLOBALS['TL_HOOKS']['executePostActions'][] = ['terminal42_node.listener.data_container', 'onExecutePostActions']; -$GLOBALS['TL_HOOKS']['replaceInsertTags'][] = ['terminal42_node.listener.insert_tags', 'onReplace']; - -/* - * User permissions - */ +// User permissions $GLOBALS['TL_PERMISSIONS'][] = 'nodeMounts'; $GLOBALS['TL_PERMISSIONS'][] = 'nodePermissions'; diff --git a/contao/dca/tl_content.php b/contao/dca/tl_content.php index 0d23f92..a3323b9 100644 --- a/contao/dca/tl_content.php +++ b/contao/dca/tl_content.php @@ -1,39 +1,24 @@ &$GLOBALS['TL_LANG']['tl_content']['nodes'], - 'exclude' => true, 'inputType' => 'nodePicker', 'eval' => ['mandatory' => true, 'multiple' => true, 'fieldType' => 'checkbox', 'tl_class' => 'clr'], - 'sql' => ['type' => 'blob', 'notnull' => false], - 'save_callback' => [ - ['terminal42_node.listener.content', 'onNodesSaveCallback'], - ], + 'sql' => ['type' => Types::BLOB, 'notnull' => false], ]; $GLOBALS['TL_DCA']['tl_content']['fields']['nodesWrapper'] = [ 'label' => &$GLOBALS['TL_LANG']['tl_content']['nodesWrapper'], - 'exclude' => true, 'inputType' => 'checkbox', 'eval' => ['submitOnChange' => true, 'tl_class' => 'clr'], - 'sql' => ['type' => 'string', 'length' => 1, 'default' => ''], + 'sql' => ['type' => Types::BOOLEAN, 'default' => false], ]; diff --git a/contao/dca/tl_module.php b/contao/dca/tl_module.php index e2916bc..ab5f9fb 100644 --- a/contao/dca/tl_module.php +++ b/contao/dca/tl_module.php @@ -6,16 +6,12 @@ Controller::loadDataContainer('tl_content'); System::loadLanguageFile('tl_content'); -/* - * Palettes - */ +// Palettes $GLOBALS['TL_DCA']['tl_module']['palettes']['__selector__'][] = 'nodesWrapper'; -$GLOBALS['TL_DCA']['tl_module']['palettes']['nodes'] = '{title_legend},name,type;{include_legend},nodes,nodesWrapper;{template_legend:hide},customTpl;{protected_legend:hide},protected;{expert_legend:hide},guests'; +$GLOBALS['TL_DCA']['tl_module']['palettes']['nodes'] = '{title_legend},name,type;{include_legend},nodes,nodesWrapper;{template_legend:collapsed},customTpl;{protected_legend:collapsed},protected;{expert_legend:collapsed},guests'; $GLOBALS['TL_DCA']['tl_module']['subpalettes']['nodesWrapper'] = &$GLOBALS['TL_DCA']['tl_content']['subpalettes']['nodesWrapper']; -/* - * Fields - */ +// Fields $GLOBALS['TL_DCA']['tl_module']['fields']['nodes'] = &$GLOBALS['TL_DCA']['tl_content']['fields']['nodes']; $GLOBALS['TL_DCA']['tl_module']['fields']['nodesWrapper'] = &$GLOBALS['TL_DCA']['tl_content']['fields']['nodesWrapper']; diff --git a/contao/dca/tl_node.php b/contao/dca/tl_node.php index 504e593..b841070 100644 --- a/contao/dca/tl_node.php +++ b/contao/dca/tl_node.php @@ -1,8 +1,8 @@ ['tl_content'], 'enableVersioning' => true, 'markAsCopy' => 'name', - 'onload_callback' => [ - ['terminal42_node.listener.data_container', 'onLoadCallback'], - ], 'sql' => [ 'keys' => [ 'id' => 'primary', @@ -30,80 +27,28 @@ 'mode' => DataContainer::MODE_TREE, 'icon' => 'folderC.svg', 'rootPaste' => true, - 'paste_button_callback' => ['terminal42_node.listener.data_container', 'onPasteButtonCallback'], 'panelLayout' => 'filter;search', ], 'label' => [ 'fields' => ['name'], 'format' => '%s', - 'label_callback' => ['terminal42_node.listener.data_container', 'onLabelCallback'], ], 'global_operations' => [ 'toggleNodes' => [ - 'label' => &$GLOBALS['TL_LANG']['MSC']['toggleAll'], 'href' => 'ptg=all', 'class' => 'header_toggle', 'showOnSelect' => true, ], - 'all' => [ - 'label' => &$GLOBALS['TL_LANG']['MSC']['all'], - 'href' => 'act=select', - 'class' => 'header_edit_all', - 'attributes' => 'onclick="Backend.getScrollOffset()" accesskey="e"', - ], - ], - 'operations' => [ - 'edit' => [ - 'label' => &$GLOBALS['TL_LANG']['tl_node']['edit'], - 'href' => 'table=tl_content', - 'icon' => 'edit.svg', - 'button_callback' => ['terminal42_node.listener.data_container', 'onEditButtonCallback'], - ], - 'editheader' => [ - 'label' => &$GLOBALS['TL_LANG']['tl_node']['editheader'], - 'href' => 'act=edit', - 'icon' => 'header.svg', - 'button_callback' => ['terminal42_node.listener.data_container', 'onEditHeaderButtonCallback'], - ], - 'copy' => [ - 'label' => &$GLOBALS['TL_LANG']['tl_node']['copy'], - 'href' => 'act=paste&mode=copy', - 'icon' => 'copy.svg', - 'attributes' => 'onclick="Backend.getScrollOffset()"', - 'button_callback' => ['terminal42_node.listener.data_container', 'onCopyButtonCallback'], - ], - 'copyChilds' => [ - 'label' => &$GLOBALS['TL_LANG']['tl_node']['copyChilds'], - 'href' => 'act=paste&mode=copy&childs=1', - 'icon' => 'copychilds.svg', - 'attributes' => 'onclick="Backend.getScrollOffset()"', - 'button_callback' => ['terminal42_node.listener.data_container', 'onCopyChildsButtonCallback'], - ], - 'cut' => [ - 'label' => &$GLOBALS['TL_LANG']['tl_node']['cut'], - 'href' => 'act=paste&mode=cut', - 'icon' => 'cut.svg', - 'attributes' => 'onclick="Backend.getScrollOffset()"', - ], - 'delete' => [ - 'label' => &$GLOBALS['TL_LANG']['tl_node']['delete'], - 'href' => 'act=delete', - 'icon' => 'delete.svg', - 'attributes' => 'onclick="if(!confirm(\''.($GLOBALS['TL_LANG']['MSC']['deleteConfirm'] ?? null).'\'))return false;Backend.getScrollOffset()"', - 'button_callback' => ['terminal42_node.listener.data_container', 'onDeleteButtonCallback'], - ], - 'show' => [ - 'label' => &$GLOBALS['TL_LANG']['tl_node']['show'], - 'href' => 'act=show', - 'icon' => 'show.svg', - ], + 'all', ], ], // Palettes 'palettes' => [ - '__selector__' => ['wrapper', 'protected'], - 'default' => '{name_legend},name,type;{wrapper_legend},wrapper;{filter_legend},languages,tags;{protected_legend:hide},protected,guests', + '__selector__' => ['type', 'wrapper', 'protected'], + 'default' => '{name_legend},name,type;{wrapper_legend},wrapper;{filter_legend},languages,tags;{protected_legend:collapsed},protected,guests', + NodeModel::TYPE_FOLDER => '{name_legend},name,type;{filter_legend},languages,tags', + NodeModel::TYPE_CONTENT => '{name_legend},name,type,alias;{wrapper_legend},wrapper;{filter_legend},languages,tags;{protected_legend:collapsed},protected,guests', ], // Subpalettes @@ -115,31 +60,27 @@ // Fields 'fields' => [ 'id' => [ - 'sql' => ['type' => 'integer', 'unsigned' => true, 'autoincrement' => true], + 'sql' => ['type' => Types::INTEGER, 'unsigned' => true, 'autoincrement' => true], ], 'pid' => [ 'label' => &$GLOBALS['TL_LANG']['tl_node']['pid'], 'foreignKey' => 'tl_node.name', - 'sql' => ['type' => 'integer', 'unsigned' => true, 'default' => 0], + 'sql' => ['type' => Types::INTEGER, 'unsigned' => true, 'default' => 0], ], 'sorting' => [ - 'sql' => ['type' => 'integer', 'unsigned' => true, 'default' => 0], + 'sql' => ['type' => Types::INTEGER, 'unsigned' => true, 'default' => 0], ], 'tstamp' => [ 'label' => &$GLOBALS['TL_LANG']['tl_node']['tstamp'], - 'sql' => ['type' => 'integer', 'unsigned' => true, 'default' => 0], + 'sql' => ['type' => Types::INTEGER, 'unsigned' => true, 'default' => 0], ], 'name' => [ - 'label' => &$GLOBALS['TL_LANG']['tl_node']['name'], - 'exclude' => true, 'search' => true, 'inputType' => 'text', 'eval' => ['mandatory' => true, 'maxlength' => 255, 'tl_class' => 'w50'], - 'sql' => ['type' => 'string', 'length' => 255, 'default' => ''], + 'sql' => ['type' => Types::STRING, 'length' => 255, 'default' => ''], ], 'type' => [ - 'label' => &$GLOBALS['TL_LANG']['tl_node']['type'], - 'exclude' => true, 'filter' => true, 'inputType' => 'select', 'options' => [ @@ -147,68 +88,51 @@ NodeModel::TYPE_FOLDER, ], 'reference' => &$GLOBALS['TL_LANG']['tl_node']['typeRef'], - 'eval' => ['tl_class' => 'w50'], - 'sql' => ['type' => 'string', 'length' => 7, 'default' => ''], + 'eval' => ['submitOnChange' => true, 'tl_class' => 'w50'], + 'sql' => ['type' => Types::STRING, 'length' => 7, 'default' => ''], + ], + 'alias' => [ + 'search' => true, + 'inputType' => 'text', + 'eval' => ['rgxp' => 'alias', 'doNotCopy' => true, 'unique' => true, 'maxlength' => 255, 'tl_class' => 'w50'], + 'sql' => ['type' => Types::STRING, 'length' => 255, 'default' => ''], ], 'wrapper' => [ - 'label' => &$GLOBALS['TL_LANG']['tl_node']['wrapper'], - 'exclude' => true, 'inputType' => 'checkbox', 'eval' => ['submitOnChange' => true, 'tl_class' => 'clr'], - 'sql' => ['type' => 'string', 'length' => 1, 'default' => ''], + 'sql' => ['type' => Types::STRING, 'length' => 1, 'default' => ''], ], 'nodeTpl' => [ - 'exclude' => true, 'inputType' => 'select', - 'options_callback' => static fn () => Controller::getTemplateGroup('node_'), 'eval' => ['includeBlankOption' => true, 'chosen' => true, 'tl_class' => 'w50'], - 'sql' => ['type' => 'string', 'length' => 64, 'default' => ''], + 'sql' => ['type' => Types::STRING, 'length' => 64, 'default' => ''], ], 'cssID' => [ - 'label' => &$GLOBALS['TL_LANG']['tl_node']['cssID'], - 'exclude' => true, 'inputType' => 'text', 'eval' => ['multiple' => true, 'size' => 2, 'tl_class' => 'w50'], - 'sql' => ['type' => 'string', 'length' => 255, 'default' => ''], + 'sql' => ['type' => Types::STRING, 'length' => 255, 'default' => ''], ], 'languages' => [ - 'label' => &$GLOBALS['TL_LANG']['tl_node']['languages'], - 'exclude' => true, 'filter' => true, 'inputType' => 'select', - 'options_callback' => ['terminal42_node.listener.data_container', 'onLanguagesOptionsCallback'], 'eval' => ['multiple' => true, 'chosen' => true, 'csv' => ',', 'tl_class' => 'clr'], - 'sql' => ['type' => 'string', 'length' => 255, 'default' => ''], + 'sql' => ['type' => Types::STRING, 'length' => 255, 'default' => ''], ], 'tags' => [ - 'label' => &$GLOBALS['TL_LANG']['tl_node']['tags'], - 'exclude' => true, 'filter' => true, 'inputType' => 'cfgTags', 'eval' => ['tagsManager' => 'terminal42_node', 'tl_class' => 'clr'], ], 'protected' => [ - 'label' => &$GLOBALS['TL_LANG']['tl_node']['protected'], - 'exclude' => true, 'inputType' => 'checkbox', 'eval' => ['submitOnChange' => true], - 'sql' => "char(1) NOT NULL default ''", + 'sql' => ['type' => Types::BOOLEAN, 'default' => false], ], 'groups' => [ - 'label' => &$GLOBALS['TL_LANG']['tl_node']['groups'], - 'exclude' => true, 'inputType' => 'checkbox', - 'foreignKey' => 'tl_member_group.name', 'eval' => ['mandatory' => true, 'multiple' => true], - 'sql' => 'blob NULL', - 'relation' => ['type' => 'hasMany', 'load' => 'lazy'], - ], - 'guests' => [ - 'label' => &$GLOBALS['TL_LANG']['tl_node']['guests'], - 'exclude' => true, - 'inputType' => 'checkbox', - 'eval' => ['tl_class' => 'clr'], - 'sql' => "char(1) NOT NULL default ''", + 'sql' => ['type' => Types::BLOB, 'notnull' => false], + 'relation' => ['type' => 'hasMany', 'load' => 'lazy', 'table' => 'tl_member_group'], ], ], ]; diff --git a/contao/dca/tl_user.php b/contao/dca/tl_user.php index 3639092..7f533d5 100644 --- a/contao/dca/tl_user.php +++ b/contao/dca/tl_user.php @@ -1,42 +1,29 @@ addLegend('node_legend', 'pagemounts_legend', PaletteManipulator::POSITION_AFTER) - ->addField('nodeMounts', 'node_legend', PaletteManipulator::POSITION_APPEND) - ->addField('nodePermissions', 'node_legend', PaletteManipulator::POSITION_APPEND) + ->addLegend('node_legend', 'pagemounts_legend') + ->addField(['nodeMounts', 'nodePermissions'], 'node_legend', PaletteManipulator::POSITION_APPEND) ->applyToPalette('extend', 'tl_user') ->applyToPalette('custom', 'tl_user') ; -/* - * Fields - */ +// Fields $GLOBALS['TL_DCA']['tl_user']['fields']['nodeMounts'] = [ 'label' => &$GLOBALS['TL_LANG']['tl_user']['nodeMounts'], - 'exclude' => true, 'inputType' => 'nodePicker', 'eval' => ['multiple' => true, 'fieldType' => 'checkbox', 'tl_class' => 'clr'], - 'sql' => ['type' => 'blob', 'notnull' => false], + 'sql' => ['type' => Types::BLOB, 'notnull' => false], ]; $GLOBALS['TL_DCA']['tl_user']['fields']['nodePermissions'] = [ 'label' => &$GLOBALS['TL_LANG']['tl_user']['nodePermissions'], - 'exclude' => true, 'inputType' => 'checkbox', - 'options' => [ - PermissionChecker::PERMISSION_CREATE, - PermissionChecker::PERMISSION_EDIT, - PermissionChecker::PERMISSION_DELETE, - PermissionChecker::PERMISSION_CONTENT, - PermissionChecker::PERMISSION_ROOT, - ], + 'options' => ['create', 'edit', 'delete', 'content', 'root'], 'reference' => &$GLOBALS['TL_LANG']['tl_user']['nodePermissionsRef'], 'eval' => ['multiple' => true, 'fieldType' => 'checkbox', 'tl_class' => 'clr'], - 'sql' => ['type' => 'blob', 'notnull' => false], + 'sql' => ['type' => Types::BLOB, 'notnull' => false], ]; diff --git a/contao/dca/tl_user_group.php b/contao/dca/tl_user_group.php index ffadd2a..362df85 100644 --- a/contao/dca/tl_user_group.php +++ b/contao/dca/tl_user_group.php @@ -7,18 +7,13 @@ Controller::loadDataContainer('tl_user'); System::loadLanguageFile('tl_user'); -/* - * Palettes - */ +// Palettes PaletteManipulator::create() ->addLegend('node_legend', 'pagemounts_legend', PaletteManipulator::POSITION_AFTER) - ->addField('nodeMounts', 'node_legend', PaletteManipulator::POSITION_APPEND) - ->addField('nodePermissions', 'node_legend', PaletteManipulator::POSITION_APPEND) + ->addField(['nodeMounts', 'nodePermissions'], 'node_legend', PaletteManipulator::POSITION_APPEND) ->applyToPalette('default', 'tl_user_group') ; -/* - * Fields - */ +// Fields $GLOBALS['TL_DCA']['tl_user_group']['fields']['nodeMounts'] = &$GLOBALS['TL_DCA']['tl_user']['fields']['nodeMounts']; $GLOBALS['TL_DCA']['tl_user_group']['fields']['nodePermissions'] = &$GLOBALS['TL_DCA']['tl_user']['fields']['nodePermissions']; diff --git a/contao/languages/cs/default.php b/contao/languages/cs/default.php index 2667de0..29d4d86 100644 --- a/contao/languages/cs/default.php +++ b/contao/languages/cs/default.php @@ -1,4 +1,5 @@ 'Vytvořit prvky', - PermissionChecker::PERMISSION_EDIT => 'Upravit prvky', - PermissionChecker::PERMISSION_DELETE => 'Smazat prvky', - PermissionChecker::PERMISSION_CONTENT => 'Spravovat prvky', - PermissionChecker::PERMISSION_ROOT => 'Spravovat klíčové prvky', + 'create' => 'Vytvořit prvky', + 'edit' => 'Upravit prvky', + 'delete' => 'Smazat prvky', + 'content' => 'Spravovat prvky', + 'root' => 'Spravovat klíčové prvky', ]; /* diff --git a/contao/languages/de/default.php b/contao/languages/de/default.php index 1ff787d..5bba044 100644 --- a/contao/languages/de/default.php +++ b/contao/languages/de/default.php @@ -1,4 +1,5 @@ 'Nodes erstellen', - PermissionChecker::PERMISSION_EDIT => 'Nodes bearbeiten', - PermissionChecker::PERMISSION_DELETE => 'Nodes löschen', - PermissionChecker::PERMISSION_CONTENT => 'Nodes verwalten', - PermissionChecker::PERMISSION_ROOT => 'Root Nodes verwalten', + 'create' => 'Nodes erstellen', + 'edit' => 'Nodes bearbeiten', + 'delete' => 'Nodes löschen', + 'content' => 'Nodes verwalten', + 'root' => 'Root Nodes verwalten', ]; /* diff --git a/contao/languages/en/default.php b/contao/languages/en/default.php index e84bd31..ddf43c4 100755 --- a/contao/languages/en/default.php +++ b/contao/languages/en/default.php @@ -1,4 +1,5 @@ 'Create nodes', - PermissionChecker::PERMISSION_EDIT => 'Edit nodes', - PermissionChecker::PERMISSION_DELETE => 'Delete nodes', - PermissionChecker::PERMISSION_CONTENT => 'Manage content', - PermissionChecker::PERMISSION_ROOT => 'Manage root nodes', + 'create' => 'Create nodes', + 'edit' => 'Edit nodes', + 'delete' => 'Delete nodes', + 'content' => 'Manage content', + 'root' => 'Manage root nodes', ]; /* diff --git a/contao/templates/.twig-root b/contao/templates/.twig-root new file mode 100644 index 0000000..e69de29 diff --git a/contao/templates/backend/nodes_wildcard.html.twig b/contao/templates/backend/nodes_wildcard.html.twig new file mode 100644 index 0000000..eaf3a53 --- /dev/null +++ b/contao/templates/backend/nodes_wildcard.html.twig @@ -0,0 +1,15 @@ +
+ ### {{ label }} ### + {% if nodes %} +
+ {% for node in nodes %} + {% set node_href = path('contao_backend', {do: 'nodes', table: 'tl_content', id: node.id}) %} + {{ node.name}} (ID: {{ node.id }})
+ {% endfor %} + {% endif %} + {% if hrefParams ?? false %} +
+ {% set href = path('contao_backend', hrefParams) %} + {{ name }} (ID: {{ id }}) + {% endif %} +
diff --git a/contao/templates/content_element/nodes.html.twig b/contao/templates/content_element/nodes.html.twig new file mode 100644 index 0000000..40ebc37 --- /dev/null +++ b/contao/templates/content_element/nodes.html.twig @@ -0,0 +1,17 @@ +{% extends "@Contao/content_element/_base.html.twig" %} + +{% block wrapper %} + {% if nodes_wrapper ?? false %} + <{% block wrapper_tag %}div{% endblock %}{% block attributes %}{{ attributes }}{% endblock %}> + {% endif %} + + {%- block inner %} + {% for node in nodes %} + {{ node|raw }} + {% endfor %} + {% endblock -%} + + {% if nodes_wrapper ?? false %} + + {% endif %} +{% endblock %} diff --git a/contao/templates/elements/ce_nodes.html5 b/contao/templates/elements/ce_nodes.html5 deleted file mode 100644 index 9331a1f..0000000 --- a/contao/templates/elements/ce_nodes.html5 +++ /dev/null @@ -1,9 +0,0 @@ -nodesWrapper): ?> - class): ?> class="class ?>"cssID ?>> - - -nodes) ?> - -nodesWrapper): ?> - - diff --git a/contao/templates/frontend_module/nodes.html.twig b/contao/templates/frontend_module/nodes.html.twig new file mode 100644 index 0000000..c645881 --- /dev/null +++ b/contao/templates/frontend_module/nodes.html.twig @@ -0,0 +1,17 @@ +{% extends "@Contao/frontend_module/_base.html.twig" %} + +{% block wrapper %} + {% if nodes_wrapper ?? false %} + <{% block wrapper_tag %}div{% endblock %}{% block attributes %}{{ attributes }}{% endblock %}> + {% endif %} + + {%- block inner %} + {% for node in nodes %} + {{ node|raw }} + {% endfor %} + {% endblock -%} + + {% if nodes_wrapper ?? false %} + + {% endif %} +{% endblock %} diff --git a/contao/templates/modules/mod_nodes.html5 b/contao/templates/modules/mod_nodes.html5 deleted file mode 100644 index 9331a1f..0000000 --- a/contao/templates/modules/mod_nodes.html5 +++ /dev/null @@ -1,9 +0,0 @@ -nodesWrapper): ?> - class): ?> class="class ?>"cssID ?>> - - -nodes) ?> - -nodesWrapper): ?> - - diff --git a/contao/templates/node/default.html.twig b/contao/templates/node/default.html.twig new file mode 100644 index 0000000..fe730fc --- /dev/null +++ b/contao/templates/node/default.html.twig @@ -0,0 +1,11 @@ +{% set attributes = attrs() + .setIfExists('id', cssID) + .addClass(['node_wrapper', class]) + .mergeWith(attributes|default) +%} + + + {% for element in elements %} + {{ element.renderedHtml|raw }} + {% endfor %} + diff --git a/contao/templates/node_default.html5 b/contao/templates/node_default.html5 deleted file mode 100644 index 28f3651..0000000 --- a/contao/templates/node_default.html5 +++ /dev/null @@ -1,3 +0,0 @@ -cssID): ?> id="cssID; ?>" class="node_wrapper class; ?>"> - buffer; ?> - diff --git a/src/ContaoManager/Plugin.php b/src/ContaoManager/Plugin.php index 067dfed..2b23c59 100755 --- a/src/ContaoManager/Plugin.php +++ b/src/ContaoManager/Plugin.php @@ -4,6 +4,7 @@ namespace Terminal42\NodeBundle\ContaoManager; +use Codefog\HasteBundle\CodefogHasteBundle; use Contao\CoreBundle\ContaoCoreBundle; use Contao\ManagerPlugin\Bundle\BundlePluginInterface; use Contao\ManagerPlugin\Bundle\Config\BundleConfig; @@ -19,7 +20,7 @@ public function getBundles(ParserInterface $parser): array { return [ BundleConfig::create(Terminal42NodeBundle::class) - ->setLoadAfter([ContaoCoreBundle::class, 'haste', Terminal42Geoip2CountryBundle::class]), + ->setLoadAfter([ContaoCoreBundle::class, CodefogHasteBundle::class, Terminal42Geoip2CountryBundle::class]), ]; } diff --git a/src/ContentElement/NodesContentElement.php b/src/ContentElement/NodesContentElement.php deleted file mode 100644 index 672f43e..0000000 --- a/src/ContentElement/NodesContentElement.php +++ /dev/null @@ -1,130 +0,0 @@ -objModel->nodes, true))) { - return ''; - } - - $ids = array_map('intval', $ids); - - // Check for potential circular reference - if ('tl_node' === $this->objModel->ptable && \in_array((int) $this->objModel->pid, $ids, true)) { - /** @var Request $request */ - $request = System::getContainer()->get('request_stack')->getCurrentRequest(); - - if (null !== $request) { - /** @var ScopeMatcher $scopeMatcher */ - $scopeMatcher = System::getContainer()->get('contao.routing.scope_matcher'); - - if ($scopeMatcher->isBackendRequest($request)) { - return \sprintf('%s', $GLOBALS['TL_LANG']['ERR']['circularReference']); - } - } - - return ''; - } - - /** @var Request $request */ - $request = System::getContainer()->get('request_stack')->getCurrentRequest(); - - // Display the backend wildcard - if (null !== $request) { - /** @var ScopeMatcher $scopeMatcher */ - $scopeMatcher = System::getContainer()->get('contao.routing.scope_matcher'); - - if ($scopeMatcher->isBackendRequest($request)) { - return static::generateBackendWildcard($this->arrData, $ids); - } - } - - $this->nodes = System::getContainer()->get('terminal42_node.manager')->generateMultiple($ids); - - if (0 === \count($this->nodes)) { - return ''; - } - - return parent::generate(); - } - - /** - * Generate a wildcard in the backend. - */ - public static function generateBackendWildcard(array $data, array $ids): string - { - $nodes = []; - - $ids = array_map('intval', $ids); - - $nodeModels = NodeModel::findBy( - ['id IN ('.implode(',', $ids).')', 'type=?'], - [NodeModel::TYPE_CONTENT, implode(',', $ids)], - ['order' => 'FIND_IN_SET(`id`, ?)'], - ); - - if (null !== $nodeModels) { - $router = System::getContainer()->get('router'); - - /** @var NodeModel $nodeModel */ - foreach ($nodeModels as $nodeModel) { - $nodes[] = \sprintf( - '%s (ID: %s)', - $router->generate('contao_backend', ['do' => 'nodes', 'table' => 'tl_content', 'id' => $nodeModel->id]), - $nodeModel->name, - $nodeModel->id, - ); - } - } - - $wildcard = '### '.strtoupper($GLOBALS['TL_LANG']['FMD'][$data['type']][0]).' ###'; - - // Add nodes - if (\count($nodes) > 0) { - $wildcard .= '

'.implode('
', $nodes).'

'; - } - - $template = new BackendTemplate('be_wildcard'); - $template->wildcard = $wildcard; - - return $template->parse(); - } - - /** - * Generate the module. - */ - protected function compile(): void - { - $this->Template->nodes = $this->nodes; - } -} diff --git a/src/Controller/ContentElement/NodesController.php b/src/Controller/ContentElement/NodesController.php new file mode 100644 index 0000000..8043b84 --- /dev/null +++ b/src/Controller/ContentElement/NodesController.php @@ -0,0 +1,42 @@ +isBackendScope($request)) { + return $this->generateNodesBackendResponse($model); + } + + return $this->generateNodesResponse($template, $model); + } +} diff --git a/src/Controller/FrontendModule/NodesController.php b/src/Controller/FrontendModule/NodesController.php new file mode 100644 index 0000000..95897ef --- /dev/null +++ b/src/Controller/FrontendModule/NodesController.php @@ -0,0 +1,38 @@ +generateNodesResponse($template, $model); + } +} diff --git a/src/Controller/NodesTrait.php b/src/Controller/NodesTrait.php new file mode 100644 index 0000000..40054a5 --- /dev/null +++ b/src/Controller/NodesTrait.php @@ -0,0 +1,97 @@ +nodes); + + if (!\is_array($ids) || [] === $ids) { + return []; + } + + return array_map(intval(...), $ids); + } + + private function generateNodesResponse(FragmentTemplate $template, Model $model): Response + { + $ids = $this->getNodeIdsFromModel($model); + + if ([] === $ids || $this->isCircularReference($model, $ids)) { + return new Response(); + } + + $nodes = $this->nodeManager->generateMultiple($ids); + + if ([] === $nodes) { + return new Response(); + } + + $template->set('nodes', $nodes); + $template->set('nodes_wrapper', (bool) $model->nodesWrapper); + + return $template->getResponse(); + } + + private function generateNodesBackendResponse(Model $model): Response + { + $ids = $this->getNodeIdsFromModel($model); + + if ([] === $ids) { + return new Response(); + } + + if ($this->isCircularReference($model, $ids)) { + return new Response(\sprintf('%s', $this->container->get('translator')->trans('ERR.circularReference', [], 'contao_default'))); + } + + $nodeModels = NodeModel::findBy( + ['id IN ('.implode(',', $ids).')', 'type=?'], + [NodeModel::TYPE_CONTENT, implode(',', $ids)], + ['order' => 'FIND_IN_SET(`id`, ?)'], + ); + + $context = [ + 'id' => $model->id, + 'nodes' => $nodeModels?->fetchAll() ?? [], + ]; + + if ($model instanceof ContentModel) { + $context['label'] = $this->container->get('translator')->trans('CTE.nodes.0', [], 'contao_default'); + } elseif ($model instanceof ModuleModel) { + $context['label'] = $this->container->get('translator')->trans('FMD.nodes.0', [], 'contao_modules'); + $context['name'] = $model->name; + $context['hrefParams'] = ['do' => 'themes', 'table' => 'tl_module', 'act' => 'edit', 'id' => $model->id, 'rt' => $this->container->get('contao.csrf.token_manager')->getDefaultTokenValue()]; + } + + return $this->render('@Contao/backend/nodes_wildcard.html.twig', $context); + } + + private function isCircularReference(Model $model, array $ids): bool + { + if (!$model instanceof ContentModel) { + return false; + } + + if ('tl_node' !== $model->ptable) { + return false; + } + + return \in_array((int) $model->pid, $ids, true); + } +} diff --git a/src/DependencyInjection/Terminal42NodeExtension.php b/src/DependencyInjection/Terminal42NodeExtension.php index dde26c5..4ebdf1b 100755 --- a/src/DependencyInjection/Terminal42NodeExtension.php +++ b/src/DependencyInjection/Terminal42NodeExtension.php @@ -7,15 +7,20 @@ use Symfony\Component\Config\FileLocator; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Extension\Extension; -use Symfony\Component\DependencyInjection\Loader\YamlFileLoader; +use Symfony\Component\DependencyInjection\Loader\PhpFileLoader; use Terminal42\Geoip2CountryBundle\DependencyInjection\Configuration; class Terminal42NodeExtension extends Extension { public function load(array $configs, ContainerBuilder $container): void { - $loader = new YamlFileLoader($container, new FileLocator(__DIR__.'/../../config')); - $loader->load('services.yml'); + $loader = new PhpFileLoader($container, new FileLocator(__DIR__.'/../../config')); + + $loader->load('controllers.php'); + $loader->load('listeners.php'); + $loader->load('migrations.php'); + $loader->load('security.php'); + $loader->load('services.php'); if (class_exists(Configuration::class)) { Configuration::addDefaultTable('tl_node'); diff --git a/src/EventListener/ContentListener.php b/src/EventListener/ContentListener.php index 5315e12..9013b08 100644 --- a/src/EventListener/ContentListener.php +++ b/src/EventListener/ContentListener.php @@ -4,89 +4,26 @@ namespace Terminal42\NodeBundle\EventListener; -use Contao\CoreBundle\Exception\AccessDeniedException; +use Contao\CoreBundle\DependencyInjection\Attribute\AsCallback; use Contao\DataContainer; -use Contao\Input; use Contao\StringUtil; use Doctrine\DBAL\Connection; use Terminal42\NodeBundle\Model\NodeModel; -use Terminal42\NodeBundle\PermissionChecker; class ContentListener { - /** - * ContentListener constructor. - */ - public function __construct( - private Connection $db, - private PermissionChecker $permissionChecker, - ) { - } - - /** - * On data container load callback. - */ - public function onLoadCallback(DataContainer $dc): void + public function __construct(private readonly Connection $connection) { - switch (Input::get('act')) { - case 'edit': - case 'delete': - case 'show': - $nodeId = $this->db->fetchOne('SELECT pid FROM tl_content WHERE id=? AND ptable=?', [$dc->id, 'tl_node']); - break; - - case 'paste': - if ('create' === Input::get('mode')) { - $nodeId = $dc->id; - } else { - $nodeId = $this->db->fetchOne('SELECT pid FROM tl_content WHERE id=? AND ptable=?', [$dc->id, 'tl_node']); - } - break; - - case 'create': - case 'copy': - case 'copyAll': - case 'cut': - case 'cutAll': - if (1 === (int) Input::get('mode')) { - $nodeId = $this->db->fetchOne('SELECT pid FROM tl_content WHERE id=? AND ptable=?', [Input::get('pid'), 'tl_node']); - } else { - $nodeId = Input::get('pid'); - } - break; - - default: - // Ajax requests such as toggle - if (Input::get('field') && ($id = Input::get('cid') ?: Input::get('id'))) { - $nodeId = $this->db->fetchOne('SELECT pid FROM tl_content WHERE id=? AND ptable=?', [$id, 'tl_node']); - } else { - $nodeId = $dc->id; - } - break; - } - - $type = $this->db->fetchOne('SELECT type FROM tl_node WHERE id=?', [$nodeId]); - - // Throw an exception if the node is not present or is of a folder type - if (!$type || NodeModel::TYPE_FOLDER === $type) { - throw new AccessDeniedException('Node of folder type cannot have content elements'); - } - - $this->checkPermissions((int) $nodeId); } - /** - * On nodes fields save callback. - * - * @throws \InvalidArgumentException - */ + #[AsCallback('tl_content', 'fields.nodes.save')] public function onNodesSaveCallback(string|null $value, DataContainer $dc): string|null { $ids = (array) StringUtil::deserialize($value, true); $ids = array_map('intval', $ids); if (\count($ids) > 0) { - $folders = $this->db->fetchAllAssociative('SELECT name FROM tl_node WHERE id IN ('.implode(', ', $ids).') AND type=?', [NodeModel::TYPE_FOLDER]); + $folders = $this->connection->fetchAllAssociative('SELECT name FROM tl_node WHERE id IN ('.implode(', ', $ids).') AND type=?', [NodeModel::TYPE_FOLDER]); // Do not allow folder nodes if (\count($folders) > 0) { @@ -103,18 +40,4 @@ public function onNodesSaveCallback(string|null $value, DataContainer $dc): stri return $value; } - - /** - * Check the permissions. - */ - private function checkPermissions(int $nodeId): void - { - if (!$this->permissionChecker->hasUserPermission(PermissionChecker::PERMISSION_CONTENT)) { - throw new AccessDeniedException('The user is not allowed to manage the node content'); - } - - if (!$this->permissionChecker->isUserAllowedNode($nodeId)) { - throw new AccessDeniedException(\sprintf('The user is not allowed to manage the content of node ID %s', $nodeId)); - } - } } diff --git a/src/EventListener/DataContainerListener.php b/src/EventListener/DataContainerListener.php index 5f746a1..1809a43 100755 --- a/src/EventListener/DataContainerListener.php +++ b/src/EventListener/DataContainerListener.php @@ -8,11 +8,17 @@ use Codefog\TagsBundle\Manager\ManagerInterface; use Codefog\TagsBundle\Tag; use Contao\Backend; +use Contao\BackendUser; use Contao\Controller; +use Contao\CoreBundle\DependencyInjection\Attribute\AsCallback; +use Contao\CoreBundle\DependencyInjection\Attribute\AsHook; use Contao\CoreBundle\Exception\AccessDeniedException; use Contao\CoreBundle\Exception\ResponseException; use Contao\CoreBundle\Intl\Locales; use Contao\CoreBundle\Monolog\ContaoContext; +use Contao\CoreBundle\Security\ContaoCorePermissions; +use Contao\CoreBundle\Security\DataContainer\ReadAction; +use Contao\CoreBundle\Twig\Finder\FinderFactory; use Contao\DataContainer; use Contao\Environment; use Contao\Image; @@ -21,15 +27,16 @@ use Contao\System; use Contao\Validator; use Doctrine\DBAL\Connection; -use Haste\Model\Model; use Psr\Log\LoggerInterface; use Psr\Log\LogLevel; +use Symfony\Bundle\SecurityBundle\Security; use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Session\Attribute\AttributeBagInterface; use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; +use Symfony\Contracts\Translation\TranslatorInterface; use Terminal42\NodeBundle\Model\NodeModel; -use Terminal42\NodeBundle\PermissionChecker; +use Terminal42\NodeBundle\Security\NodePermissions; use Terminal42\NodeBundle\Widget\NodePickerWidget; class DataContainerListener @@ -37,28 +44,26 @@ class DataContainerListener public const BREADCRUMB_SESSION_KEY = 'tl_node_node'; public function __construct( - private Connection $db, - private Locales $locales, - private LoggerInterface $logger, - private PermissionChecker $permissionChecker, - private RequestStack $requestStack, - private ManagerInterface $tagsManager, + private readonly Connection $connection, + private readonly FinderFactory $finderFactory, + private readonly Locales $locales, + private readonly LoggerInterface $logger, + private readonly RequestStack $requestStack, + private readonly Security $security, + private readonly ManagerInterface $tagsManager, + private readonly TranslatorInterface $translator, ) { } - /** - * On load callback. - */ + #[AsCallback('tl_node', 'config.onload')] public function onLoadCallback(DataContainer $dc): void { $this->addBreadcrumb($dc); - $this->checkPermissions($dc); + $this->checkPermissions(); $this->toggleSwitchToEditFlag($dc); } - /** - * On paste button callback. - */ + #[AsCallback('tl_node', 'list.sorting.paste_button_callback')] public function onPasteButtonCallback(DataContainer $dc, array $row, string $table, bool $cr, array|false|null $clipboard = null): string { $disablePA = false; @@ -75,9 +80,8 @@ public function onPasteButtonCallback(DataContainer $dc, array $row, string $tab $disablePI = true; } - // Disable "paste after" button if the parent node is a root node and the user is - // not allowed - if (!$disablePA && !$this->permissionChecker->hasUserPermission(PermissionChecker::PERMISSION_ROOT) && (!$row['pid'] || \in_array((int) $row['id'], $dc->rootIds, true))) { + // Disable "paste after" button if the parent node is a root node and the user is not allowed + if (!$disablePA && !$this->security->isGranted(NodePermissions::USER_CAN_CREATE_ROOT_NODES) && (!$row['pid'] || \in_array((int) $row['id'], $dc->rootIds, true))) { $disablePA = true; } @@ -94,77 +98,13 @@ public function onPasteButtonCallback(DataContainer $dc, array $row, string $tab return $return.($disablePI ? Image::getHtml('pasteinto_.svg').' ' : ''.$imagePasteInto.' '); } - /** - * On "edit" button callback. - */ - public function onEditButtonCallback(array $row, string $href, string $label, string $title, string $icon, string $attributes): string - { - return $this->generateButton($row, $href, $label, $title, $icon, $attributes, NodeModel::TYPE_FOLDER !== $row['type'] && $this->permissionChecker->hasUserPermission(PermissionChecker::PERMISSION_CONTENT)); - } - - /** - * On "edit header" button callback. - */ - public function onEditHeaderButtonCallback(array $row, string $href, string $label, string $title, string $icon, string $attributes): string + #[AsCallback('tl_node', 'list.operations.children.button')] + public function onChildrenButtonCallback(array $row, string $href, string $label, string $title, string $icon, string $attributes): string { - return $this->generateButton($row, $href, $label, $title, $icon, $attributes, $this->permissionChecker->hasUserPermission(PermissionChecker::PERMISSION_EDIT)); + return $this->generateButton($row, $href, $label, $title, $icon, $attributes, NodeModel::TYPE_FOLDER !== $row['type']); } - /** - * On "copy" button callback. - */ - public function onCopyButtonCallback(array $row, string $href, string $label, string $title, string $icon, string $attributes, string $table): string - { - if ($GLOBALS['TL_DCA'][$table]['config']['closed'] ?? null) { - return ''; - } - - return $this->generateButton($row, $href, $label, $title, $icon, $attributes, $this->permissionChecker->hasUserPermission(PermissionChecker::PERMISSION_CREATE)); - } - - /** - * On "copy childs" button callback. - */ - public function onCopyChildsButtonCallback(array $row, string $href, string $label, string $title, string $icon, string $attributes, string $table): string - { - if ($GLOBALS['TL_DCA'][$table]['config']['closed'] ?? null) { - return ''; - } - - $active = (NodeModel::TYPE_FOLDER === $row['type']) && $this->permissionChecker->hasUserPermission(PermissionChecker::PERMISSION_CREATE); - - // Make the button active only if there are subnodes - if ($active) { - $active = $this->db->fetchOne("SELECT COUNT(*) FROM $table WHERE pid=?", [$row['id']]) > 0; - } - - return $this->generateButton($row, $href, $label, $title, $icon, $attributes, $active); - } - - /** - * On "delete" button callback. - */ - public function onDeleteButtonCallback(array $row, string $href, string $label, string $title, string $icon, string $attributes): string - { - $active = true; - - // Allow delete if the user has permission - if (!$this->permissionChecker->isUserAdmin()) { - $rootIds = (array) func_get_arg(7); - $active = $this->permissionChecker->hasUserPermission(PermissionChecker::PERMISSION_DELETE); - - // If the node is a root one, check if the user has permission to manage it - if ($active && \in_array((int) $row['id'], $rootIds, true)) { - $active = $this->permissionChecker->hasUserPermission(PermissionChecker::PERMISSION_ROOT); - } - } - - return $this->generateButton($row, $href, $label, $title, $icon, $attributes, $active); - } - - /** - * On label callback. - */ + #[AsCallback('tl_node', 'list.label.label')] public function onLabelCallback(array $row, string $label, DataContainer|null $dc = null, string $imageAttribute = '', bool $returnImage = false): string { $image = NodeModel::TYPE_CONTENT === $row['type'] ? 'articles.svg' : 'folderC.svg'; @@ -183,12 +123,7 @@ public function onLabelCallback(array $row, string $label, DataContainer|null $d } $tags = []; - - if (class_exists(DcaRelationsModel::class)) { - $tagIds = DcaRelationsModel::getRelatedValues('tl_node', 'tags', $row['id']); - } else { - $tagIds = Model::getRelatedValues('tl_node', 'tags', $row['id']); - } + $tagIds = DcaRelationsModel::getRelatedValues('tl_node', 'tags', $row['id']); // Generate the tags if (\count($tagIds) > 0) { @@ -198,31 +133,67 @@ public function onLabelCallback(array $row, string $label, DataContainer|null $d } } + $extras = []; + + if ([] !== $languages) { + $extras[] = implode(', ', $languages); + } + + if ([] !== $tags) { + $extras[] = implode(', ', $tags); + } + + if (NodeModel::TYPE_CONTENT === $row['type']) { + $extras[] = \sprintf('ID: %d', $row['id']); + + if ($row['alias']) { + $extras[] = \sprintf('%s: %s', $GLOBALS['TL_LANG']['tl_node']['alias'][0], $row['alias']); + } + } + return \sprintf( - '%s %s%s%s', + '%s %s%s', Image::getHtml($image, '', $imageAttribute), Backend::addToUrl('nn='.$row['id']), StringUtil::specialchars($GLOBALS['TL_LANG']['MSC']['selectNode']), $label, - \count($languages) > 0 ? \sprintf(' [%s]', implode(', ', $languages)) : '', - \count($tags) > 0 ? \sprintf(' [%s]', implode(', ', $tags)) : '', + $extras ? ' '.implode('', array_map(static fn (string $v) => \sprintf('[%s]', $v), $extras)) : '', ); } - /** - * On languages options callback. - */ + #[AsCallback('tl_node', 'fields.languages.options')] public function onLanguagesOptionsCallback(): array { return $this->locales->getLocales(null, true); } - /** - * On execute the post actions. - * - * @param string $action - */ - public function onExecutePostActions($action, DataContainer $dc): void + #[AsCallback('tl_node', 'fields.groups.options')] + public function onGroupsOptionsCallback(): array + { + $options = [-1 => $this->translator->trans('MSC.guests', [], 'contao_default')]; + $groups = $this->connection->fetchAllAssociative('SELECT id, name FROM tl_member_group WHERE tstamp>0 ORDER BY name'); + + foreach ($groups as $group) { + $options[$group['id']] = $group['name']; + } + + return $options; + } + + #[AsCallback('tl_node', 'fields.nodeTpl.options')] + public function onNodeTplOptionsCallback(): array + { + return $this->finderFactory + ->create() + ->identifier('node') + ->extension('html.twig') + ->withVariants() + ->asTemplateOptions() + ; + } + + #[AsHook('executePostActions')] + public function onExecutePostActions(string $action, DataContainer $dc): void { if ('reloadNodePickerWidget' === $action) { $this->reloadNodePickerWidget($dc); @@ -272,8 +243,8 @@ private function reloadNodePickerWidget(DataContainer $dc): void $value = null; // Load the value - if ('overrideAll' !== Input::get('act') && $id > 0 && $this->db->createSchemaManager()->tablesExist([$dc->table])) { - $row = $this->db->fetchAssociative("SELECT * FROM {$dc->table} WHERE id=?", [$id]); + if ('overrideAll' !== Input::get('act') && $id > 0 && $this->connection->createSchemaManager()->tablesExist([$dc->table])) { + $row = $this->connection->fetchAssociative("SELECT * FROM {$dc->table} WHERE id=?", [$id]); // The record does not exist if (!$row) { @@ -324,110 +295,41 @@ private function reloadNodePickerWidget(DataContainer $dc): void */ private function toggleSwitchToEditFlag(DataContainer $dc): void { - if (!$dc->id || !$this->permissionChecker->hasUserPermission(PermissionChecker::PERMISSION_CONTENT)) { + if (!$dc->id) { + return; + } + + if (!$this->security->isGranted(NodePermissions::USER_CAN_EDIT_NODE_CONTENT)) { return; } - $type = $this->db->fetchOne('SELECT type FROM tl_node WHERE id=?', [$dc->id]); + $type = $this->connection->fetchOne('SELECT type FROM tl_node WHERE id=?', [$dc->id]); if (NodeModel::TYPE_CONTENT === $type) { $GLOBALS['TL_DCA'][$dc->table]['config']['switchToEdit'] = true; } } - /** - * Check the permissions. - */ - private function checkPermissions(DataContainer $dc): void + private function checkPermissions(): void { - if ($this->permissionChecker->isUserAdmin()) { - return; - } + $user = $this->security->getUser(); - // Close the table if user is not allowed to create new records - if (!$this->permissionChecker->hasUserPermission(PermissionChecker::PERMISSION_CREATE)) { - $GLOBALS['TL_DCA'][$dc->table]['config']['closed'] = true; - $GLOBALS['TL_DCA'][$dc->table]['config']['notCopyable'] = true; - } - - // Set the flag if user is not allowed to edit records - if (!$this->permissionChecker->hasUserPermission(PermissionChecker::PERMISSION_EDIT)) { - $GLOBALS['TL_DCA'][$dc->table]['config']['notEditable'] = true; + if (!$user instanceof BackendUser) { + return; } - // Set the flag if user is not allowed to delete records - if (!$this->permissionChecker->hasUserPermission(PermissionChecker::PERMISSION_DELETE)) { - $GLOBALS['TL_DCA'][$dc->table]['config']['notDeletable'] = true; + if ($user->isAdmin) { + return; } - $session = $this->requestStack->getSession()->all(); - - // Filter allowed page IDs - if (\is_array($session['CURRENT']['IDS'] ?? null)) { - $session['CURRENT']['IDS'] = $this->permissionChecker->filterAllowedIds( - $session['CURRENT']['IDS'], - 'deleteAll' === Input::get('act') ? PermissionChecker::PERMISSION_DELETE : PermissionChecker::PERMISSION_EDIT, - ); - - $this->requestStack->getSession()->replace($session); + // Set root IDs + if (empty($user->nodeMounts) || !\is_array($user->nodeMounts)) { + $root = [0]; + } else { + $root = $user->nodeMounts; } - // Limit the allowed roots for the user - if (null !== ($roots = $this->permissionChecker->getUserAllowedRoots())) { - if (!empty($GLOBALS['TL_DCA'][$dc->table]['list']['sorting']['root']) && \is_array($GLOBALS['TL_DCA'][$dc->table]['list']['sorting']['root'])) { - $GLOBALS['TL_DCA'][$dc->table]['list']['sorting']['root'] = array_intersect($GLOBALS['TL_DCA'][$dc->table]['list']['sorting']['root'], $roots); - } else { - $GLOBALS['TL_DCA'][$dc->table]['list']['sorting']['root'] = $roots; - } - - // Allow root paste if the user has enough permission - if ($this->permissionChecker->hasUserPermission(PermissionChecker::PERMISSION_ROOT)) { - $GLOBALS['TL_DCA'][$dc->table]['list']['sorting']['rootPaste'] = true; - } - - // Check current action - if (($action = Input::get('act')) && 'paste' !== $action) { - switch ($action) { - case 'edit': - $nodeId = (int) Input::get('id'); - - // Dynamically add the record to the user profile - if (!$this->permissionChecker->isUserAllowedNode($nodeId)) { - /** @var AttributeBagInterface $sessionBag */ - $sessionBag = $this->requestStack->getSession()->getbag('contao_backend'); - - $newRecords = $sessionBag->get('new_records'); - $newRecords = \is_array($newRecords[$dc->table]) ? array_map('intval', $newRecords[$dc->table]) : []; - - if (\in_array($nodeId, $newRecords, true)) { - $this->permissionChecker->addNodeToAllowedRoots($nodeId); - } - } - // no break; - - case 'copy': - case 'delete': - case 'show': - if (!isset($nodeId)) { - $nodeId = (int) Input::get('id'); - } - - if (!$this->permissionChecker->isUserAllowedNode($nodeId)) { - throw new AccessDeniedException(\sprintf('Not enough permissions to %s node ID %s.', $action, $nodeId)); - } - break; - - case 'editAll': - case 'deleteAll': - case 'overrideAll': - if (\is_array($session['CURRENT']['IDS'])) { - $session['CURRENT']['IDS'] = array_intersect($session['CURRENT']['IDS'], $roots); - $this->requestStack->getSession()->replace($session); - } - break; - } - } - } + $GLOBALS['TL_DCA']['tl_node']['list']['sorting']['root'] = $root; } /** @@ -448,7 +350,7 @@ private function addBreadcrumb(DataContainer $dc): void } $session->set(self::BREADCRUMB_SESSION_KEY, Input::get('nn', true)); - Controller::redirect(preg_replace('/&nn=[^&]*/', '', Environment::get('request'))); + Controller::redirect(preg_replace('/&nn=[^&]*/', '', (string) Environment::get('request'))); } if (($nodeId = $session->get(self::BREADCRUMB_SESSION_KEY)) < 1) { @@ -468,7 +370,7 @@ private function addBreadcrumb(DataContainer $dc): void $id = $nodeId; do { - $node = $this->db->fetchAssociative("SELECT * FROM {$dc->table} WHERE id=?", [$id]); + $node = $this->connection->fetchAssociative("SELECT * FROM {$dc->table} WHERE id=?", [$id]); if (!$node) { // Currently selected node does not exist @@ -491,7 +393,7 @@ private function addBreadcrumb(DataContainer $dc): void } // Do not show the mounted nodes - if (!$this->permissionChecker->isUserAdmin() && $this->permissionChecker->isUserAllowedRootNode($node['id'])) { + if (!$this->security->isGranted(ContaoCorePermissions::DC_PREFIX.$dc->table, new ReadAction($dc->table, $node))) { break; } @@ -500,10 +402,12 @@ private function addBreadcrumb(DataContainer $dc): void } // Check whether the node is mounted - if (!$this->permissionChecker->isUserAllowedRootNode($ids)) { - $session->set(self::BREADCRUMB_SESSION_KEY, 0); + foreach ($ids as $id) { + if (!$this->security->isGranted(ContaoCorePermissions::DC_PREFIX.$dc->table, new ReadAction($dc->table, ['id' => $id]))) { + $session->set(self::BREADCRUMB_SESSION_KEY, 0); - throw new AccessDeniedException('Node ID '.$nodeId.' is not mounted.'); + throw new AccessDeniedException('Node ID '.$nodeId.' is not mounted.'); + } } // Limit tree diff --git a/src/EventListener/InsertTagsListener.php b/src/EventListener/InsertTagsListener.php deleted file mode 100755 index 6e1a875..0000000 --- a/src/EventListener/InsertTagsListener.php +++ /dev/null @@ -1,74 +0,0 @@ -manager->generateSingle((int) $ids[0])) { - $this->logError($ids, $tag); - } - - return $buffer; - } - - $nodes = $this->manager->generateMultiple($ids); - $invalid = array_keys(array_diff_key(array_flip($ids), $nodes)); - - if (!empty($invalid)) { - $this->logError($invalid, $tag); - } - - return implode("\n", $nodes); - } - - private function logError(array $ids, string $tag): void - { - if (null === $this->logger) { - return; - } - - $this->logger->error( - 'Invalid nodes ('.implode(', ', $ids).') in insert tag ('.$tag.') on page '.Environment::get('uri'), - ['contao' => new ContaoContext(self::class, ContaoContext::ERROR)], - ); - } -} diff --git a/src/FrontendModule/NodesModule.php b/src/FrontendModule/NodesModule.php deleted file mode 100644 index 030afef..0000000 --- a/src/FrontendModule/NodesModule.php +++ /dev/null @@ -1,68 +0,0 @@ -objModel->nodes, true))) { - return ''; - } - - /** @var Request $request */ - $request = System::getContainer()->get('request_stack')->getCurrentRequest(); - - // Display the backend wildcard - if (null !== $request) { - /** @var ScopeMatcher $scopeMatcher */ - $scopeMatcher = System::getContainer()->get('contao.routing.scope_matcher'); - - if ($scopeMatcher->isBackendRequest($request)) { - return NodesContentElement::generateBackendWildcard($this->arrData, $ids); - } - } - - $this->nodes = System::getContainer()->get('terminal42_node.manager')->generateMultiple($ids); - - if (0 === \count($this->nodes)) { - return ''; - } - - return parent::generate(); - } - - /** - * Generate the module. - */ - protected function compile(): void - { - $this->Template->nodes = $this->nodes; - } -} diff --git a/src/InsertTag/NodeInsertTag.php b/src/InsertTag/NodeInsertTag.php new file mode 100644 index 0000000..9802f96 --- /dev/null +++ b/src/InsertTag/NodeInsertTag.php @@ -0,0 +1,69 @@ +getParameters()->get(0)); + $total = \count($ids); + + if (0 === $total) { + return new InsertTagResult(''); + } + + if (1 === $total) { + $buffer = $this->manager->generateSingle($ids[0]); + + if (null === $buffer) { + $this->logError($insertTag, $ids); + + return new InsertTagResult(''); + } + + return new InsertTagResult($buffer); + } + + $nodes = $this->manager->generateMultiple($ids); + $invalid = array_keys(array_diff_key(array_flip($ids), $nodes)); + + if (!empty($invalid)) { + $this->logError($insertTag, $invalid); + } + + return new InsertTagResult(implode("\n", $nodes)); + } + + private function logError(ResolvedInsertTag $insertTag, array $ids): void + { + if (null === $this->logger) { + return; + } + + $this->logger->error( + 'Invalid nodes ('.implode(', ', $ids).') in insert tag ('.$insertTag->getName().') on page '.Environment::get('uri'), + ['contao' => new ContaoContext(self::class, ContaoContext::ERROR)], + ); + } +} diff --git a/src/Migration/GuestsMigration.php b/src/Migration/GuestsMigration.php new file mode 100644 index 0000000..084ac17 --- /dev/null +++ b/src/Migration/GuestsMigration.php @@ -0,0 +1,15 @@ +|null */ public function getContentElements(): Collection|null diff --git a/src/NodeElement.php b/src/NodeElement.php new file mode 100644 index 0000000..5a5822e --- /dev/null +++ b/src/NodeElement.php @@ -0,0 +1,24 @@ +row; + } + + public function getRenderedHtml(): string + { + return $this->renderedHtml; + } +} diff --git a/src/NodeManager.php b/src/NodeManager.php index e44132c..6360c52 100644 --- a/src/NodeManager.php +++ b/src/NodeManager.php @@ -6,96 +6,112 @@ use Contao\ContentModel; use Contao\Controller; -use Contao\FrontendTemplate; use Contao\StringUtil; use Terminal42\NodeBundle\Model\NodeModel; +use Twig\Environment; class NodeManager { - /** - * Generate single node. - */ - public function generateSingle(int $id): string|null + public function __construct(private readonly Environment $twig) { - if (!$id) { + } + + public function generateSingle(int|string $idOrAlias): string|null + { + if (!$idOrAlias) { return null; } - if (null === ($nodeModel = NodeModel::findOneBy(['id=?', 'type=?'], [$id, NodeModel::TYPE_CONTENT]))) { + if (null === ($nodeModel = NodeModel::findOneBy(['(id=? OR alias=?)', 'type=?'], [$idOrAlias, $idOrAlias, NodeModel::TYPE_CONTENT]))) { return null; } return $this->generateBuffer($nodeModel); } - /** - * Generate multiple nodes. - */ - public function generateMultiple(array $ids): array + public function generateMultiple(array $idsOrAliases): array { - $ids = array_filter($ids); + $idsOrAliases = array_values(array_filter($idsOrAliases)); + + if ([] === $idsOrAliases) { + return []; + } + + $aliases = array_values(array_filter($idsOrAliases, static fn ($v) => \is_string($v) && !is_numeric($v))); + $ids = array_map(intval(...), array_values(array_diff($idsOrAliases, $aliases))); - if (0 === \count($ids)) { + if ([] === $aliases && [] === $ids) { return []; } - $ids = array_map('intval', $ids); + $columns = ['type=?']; + $values = [NodeModel::TYPE_CONTENT]; - $nodeModels = NodeModel::findBy( - ['id IN ('.implode(',', $ids).')', 'type=?'], - [NodeModel::TYPE_CONTENT, implode(',', $ids)], - ['order' => 'FIND_IN_SET(`id`, ?)'], - ); + if ([] !== $aliases) { + $columns[] = "alias IN ('".implode("','", $aliases)."')"; + } + + if ([] !== $ids) { + $columns[] = 'id IN ('.implode(',', $ids).')'; + } - if (null === $nodeModels) { + if (null === ($nodeModels = NodeModel::findBy($columns, $values))) { return []; } - $nodes = []; + $sortedNodeModels = []; /** @var NodeModel $nodeModel */ foreach ($nodeModels as $nodeModel) { + // No strict check here + $sortedNodeModels[array_search($nodeModel->alias ?: $nodeModel->id, $idsOrAliases, false)] = $nodeModel; + } + + $nodes = []; + + /** @var NodeModel $nodeModel */ + foreach ($sortedNodeModels as $nodeModel) { $nodes[$nodeModel->id] = $this->generateBuffer($nodeModel); } - return array_filter($nodes, static fn ($buffer) => null !== $buffer); + return array_filter($nodes, static fn ($buffer) => '' !== $buffer); } - /** - * Generate the node buffer (content elements). - */ private function generateBuffer(NodeModel $nodeModel): string { if (!Controller::isVisibleElement($nodeModel)) { return ''; } - $buffer = ''; - $elementsData = []; + $nodeElements = []; if (null !== ($elements = $nodeModel->getContentElements())) { /** @var ContentModel $element */ foreach ($elements as $index => $element) { - $elementsData[] = $element->row(); + // Keep the index if somebody wants to refer that inside the generated content element $element->nodeElementIndex = $index; - $buffer .= Controller::getContentElement($element); + + $nodeElements[] = new NodeElement($element->row(), Controller::getContentElement($element)); } } if (!$nodeModel->wrapper) { - return $buffer; + return implode('', array_map(static fn (NodeElement $v) => $v->getRenderedHtml(), $nodeElements)); } - $template = new FrontendTemplate($nodeModel->nodeTpl ?: 'node_default'); - $template->setData($nodeModel->row()); - $template->elementsData = $elementsData; - $cssID = StringUtil::deserialize($nodeModel->cssID, true); - $template->class = !empty($cssID[1]) ? $cssID[1] : ''; - $template->cssID = !empty($cssID[0]) ? $cssID[0] : ''; - $template->buffer = $buffer; + if ($nodeModel->nodeTpl) { + $templateName = \sprintf('@Contao/%s.html.twig', $nodeModel->nodeTpl); + } else { + $templateName = '@Contao/node/default.html.twig'; + } - return $template->parse(); + return $this->twig->render($templateName, [ + ...$nodeModel->row(), + 'elements' => $nodeElements, + 'class' => !empty($cssID[1]) ? $cssID[1] : '', + 'cssID' => !empty($cssID[0]) ? $cssID[0] : '', + ]); } } diff --git a/src/PermissionChecker.php b/src/PermissionChecker.php deleted file mode 100644 index 7d60857..0000000 --- a/src/PermissionChecker.php +++ /dev/null @@ -1,178 +0,0 @@ -authorizationChecker->isGranted('ROLE_ADMIN'); - } - - /** - * Return true if the user has permission. - */ - public function hasUserPermission(string $permission): bool - { - $isGranted = $this->authorizationChecker->isGranted('contao_user.nodePermissions.'.$permission); - - // If the user is able to create records, he is automatically able to edit them - if (!$isGranted && self::PERMISSION_EDIT === $permission) { - return $this->hasUserPermission(self::PERMISSION_CREATE); - } - - return $isGranted; - } - - /** - * Get the user allowed roots. Return null if the user has no limitation. - */ - public function getUserAllowedRoots(): array|null - { - if ($this->isUserAdmin()) { - return null; - } - - $ids = (array) $this->getUser()->nodeMounts; - - if (empty($ids)) { - return null; - } - - return array_map('intval', $ids); - } - - /** - * Check whether at least one of the given IDs is in the allowed root nodes. - * - * @param int|array $nodeId - */ - public function isUserAllowedRootNode(array|int $nodeId): bool - { - if (null === ($roots = $this->getUserAllowedRoots())) { - return true; - } - - if (!\is_array($nodeId)) { - $nodeId = [$nodeId]; - } - - return (bool) array_intersect($nodeId, $roots); - } - - /** - * Return if the user is allowed to manage the node. - */ - public function isUserAllowedNode(int $nodeId): bool - { - if (null === ($roots = $this->getUserAllowedRoots())) { - return true; - } - - // Return true if the node is a root one and user has permission to manage those - if (\in_array($nodeId, $roots, true) && $this->hasUserPermission(self::PERMISSION_ROOT)) { - return true; - } - - $ids = Database::getInstance()->getChildRecords($roots, 'tl_node', false, $roots); - $ids = array_map('intval', $ids); - - return \in_array($nodeId, $ids, true); - } - - /** - * Add the node to allowed roots. - */ - public function addNodeToAllowedRoots(int $nodeId): void - { - if (null === ($roots = $this->getUserAllowedRoots())) { - return; - } - - $user = $this->getUser(); - - // Add the permissions on group level - if ('custom' !== $user->inherit) { - $groups = $this->db->fetchAllAssociative('SELECT id, nodeMounts, nodePermissions FROM tl_user_group WHERE id IN('.implode(',', array_map('intval', $user->groups)).')'); - - foreach ($groups as $group) { - $permissions = StringUtil::deserialize($group['nodePermissions'], true); - - if (\in_array(self::PERMISSION_CREATE, $permissions, true)) { - $nodeIds = (array) StringUtil::deserialize($group['nodeMounts'], true); - $nodeIds[] = $nodeId; - - $this->db->update('tl_user_group', ['nodeMounts' => serialize($nodeIds)], ['id' => $group['id']]); - } - } - } - - // Add the permissions on user level - if ('group' !== $user->inherit) { - $userData = $this->db->fetchAssociative('SELECT nodePermissions, nodeMounts FROM tl_user WHERE id=?', [$user->id]); - $permissions = StringUtil::deserialize($userData['nodePermissions'], true); - - if (\in_array(self::PERMISSION_CREATE, $permissions, true)) { - $nodeIds = (array) StringUtil::deserialize($userData['nodeMounts'], true); - $nodeIds[] = $nodeId; - - $this->db->update('tl_user', ['nodeMounts' => serialize($nodeIds)], ['id' => $user->id]); - } - } - - // Add the new element to the user object - $user->nodeMounts = array_merge($roots, [$nodeId]); - } - - /** - * Filter the allowed IDs. - */ - public function filterAllowedIds(array $ids, string $permission): array - { - if (0 === \count($ids) || !$this->hasUserPermission($permission)) { - return []; - } - - return array_filter(array_map('intval', $ids), [$this, 'isUserAllowedNode']); - } - - private function getUser(): BackendUser - { - $user = $this->tokenStorage->getToken()?->getUser(); - - if (!$user instanceof BackendUser) { - throw new \RuntimeException('The token does not contain a back end user object'); - } - - return $user; - } -} diff --git a/src/Picker/NodePickerProvider.php b/src/Picker/NodePickerProvider.php index 5cbef14..606379a 100644 --- a/src/Picker/NodePickerProvider.php +++ b/src/Picker/NodePickerProvider.php @@ -4,10 +4,12 @@ namespace Terminal42\NodeBundle\Picker; +use Contao\CoreBundle\DependencyInjection\Attribute\AsPickerProvider; use Contao\CoreBundle\Picker\AbstractPickerProvider; use Contao\CoreBundle\Picker\DcaPickerProviderInterface; use Contao\CoreBundle\Picker\PickerConfig; +#[AsPickerProvider(priority: 132)] class NodePickerProvider extends AbstractPickerProvider implements DcaPickerProviderInterface { public function getDcaTable(PickerConfig|null $config = null): string @@ -34,10 +36,7 @@ public function getDcaAttributes(PickerConfig $config): array return $attributes; } - /** - * @param string|int $value - */ - public function convertDcaValue(PickerConfig $config, $value): int|string + public function convertDcaValue(PickerConfig $config, mixed $value): int|string { return (int) $value; } @@ -47,12 +46,7 @@ public function getName(): string return 'nodePicker'; } - /** - * Do not add "string" parameter type for compatibility with Contao 4.13. - * - * @param string $context - */ - public function supportsContext($context): bool + public function supportsContext(string $context): bool { return 'node' === $context; } diff --git a/src/Security/NodePermissions.php b/src/Security/NodePermissions.php new file mode 100644 index 0000000..554a252 --- /dev/null +++ b/src/Security/NodePermissions.php @@ -0,0 +1,22 @@ +nodeMountsCache = []; + + if ($this->inner instanceof ResetInterface) { + $this->inner->reset(); + } + } + + public function supportsAttribute(string $attribute): bool + { + if ($this->inner instanceof CacheableVoterInterface) { + return $this->inner->supportsAttribute($attribute); + } + + return false; + } + + public function supportsType(string $subjectType): bool + { + if ($this->inner instanceof CacheableVoterInterface) { + return $this->inner->supportsType($subjectType); + } + + return false; + } + + public function vote(TokenInterface $token, mixed $subject, array $attributes, Vote|null $vote = null): int + { + foreach ($attributes as $attribute) { + if (NodePermissions::USER_CAN_ACCESS_NODE === $attribute) { + return $this->voteOnAttribute($token, $subject, $attribute) ? self::ACCESS_GRANTED : self::ACCESS_DENIED; + } + } + + return $this->inner->vote($token, $subject, $attributes); + } + + private function voteOnAttribute(TokenInterface $token, mixed $subject, string $attribute): bool + { + $user = $token->getUser(); + + if (!$user instanceof BackendUser) { + return false; + } + + if ($user->isAdmin) { + return true; + } + + $permission = explode('.', $attribute, 3); + + if ('contao_user' !== $permission[0] || !isset($permission[1])) { + return false; + } + + $field = $permission[1]; + + if (null === $subject) { + return \is_array($user->$field) && [] !== $user->$field; + } + + if (!\is_scalar($subject) && !\is_array($subject)) { + return false; + } + + if (!\is_array($subject)) { + $subject = [$subject]; + } + + if (\is_array($user->$field) && array_intersect($subject, $user->$field)) { + return true; + } + + if (!isset($this->nodeMountsCache[$user->id]) || (!empty($this->nodeMountsCache[$user->id]) && !array_intersect($subject, $this->nodeMountsCache[$user->id]))) { + $database = $this->contaoFramework->createInstance(Database::class); + $this->nodeMountsCache[$user->id] = $database->getChildRecords($user->$field, 'tl_node'); + } + + return !empty($this->nodeMountsCache[$user->id]) && array_intersect($subject, $this->nodeMountsCache[$user->id]); + } +} diff --git a/src/Security/Voter/NodeContentVoter.php b/src/Security/Voter/NodeContentVoter.php new file mode 100644 index 0000000..3557c71 --- /dev/null +++ b/src/Security/Voter/NodeContentVoter.php @@ -0,0 +1,54 @@ +accessDecisionManager->decide($token, [NodePermissions::USER_CAN_ACCESS_MODULE])) { + return false; + } + + if (!$this->accessDecisionManager->decide($token, [NodePermissions::USER_CAN_EDIT_NODE_CONTENT])) { + return false; + } + + if (!$this->accessDecisionManager->decide($token, [NodePermissions::USER_CAN_ACCESS_NODE], $id)) { + return false; + } + + $type = $this->connection->fetchOne('SELECT type FROM tl_node WHERE id = ?', [$id]); + + if (NodeModel::TYPE_FOLDER === $type) { + return false; + } + + return true; + } +} diff --git a/src/Security/Voter/NodePermissionVoter.php b/src/Security/Voter/NodePermissionVoter.php new file mode 100644 index 0000000..d3abf3e --- /dev/null +++ b/src/Security/Voter/NodePermissionVoter.php @@ -0,0 +1,159 @@ +accessDecisionManager->decide($token, ['ROLE_ADMIN'])) { + return true; + } + + if (!$this->accessDecisionManager->decide($token, [NodePermissions::USER_CAN_ACCESS_MODULE])) { + return false; + } + + return match (true) { + $action instanceof CreateAction => $this->canCreate($action, $token), + $action instanceof ReadAction => $this->canRead($action, $token), + $action instanceof UpdateAction => $this->canUpdate($action, $token), + default => $this->canDelete($action, $token), // DeleteAction + }; + } + + private function canCreate(CreateAction $action, TokenInterface $token): bool + { + if (!$this->accessDecisionManager->decide($token, [NodePermissions::USER_CAN_CREATE_NODE])) { + return false; + } + + $newAction = $action->getNew(); + + // The copy operation is allowed if edit is allowed. + if (null !== $newAction && null === ($newAction['sorting'] ?? null)) { + $nodeId = $action->getNewId(); + + return $this->canEdit($token, $nodeId) && $this->canCreate(new CreateAction($action->getDataSource()), $token); + } + + // Check access to any node for the "new" operation. + if (null === $action->getNewPid()) { + $nodeIds = $this->getNodeMounts($token); + } else { + $nodeIds = [(int) $action->getNewPid()]; + } + + // To create a record, the edit permissions must be available. + foreach ($nodeIds as $nodeId) { + if ($this->canEdit($token, $nodeId)) { + return true; + } + } + + return false; + } + + private function canRead(ReadAction $action, TokenInterface $token): bool + { + return $this->canAccessNode($token, $action->getCurrentId()); + } + + private function canUpdate(UpdateAction $action, TokenInterface $token): bool + { + $nodeId = $action->getCurrentId(); + + if (!$this->canAccessNode($token, $nodeId)) { + return false; + } + + $newRecord = $action->getNew(); + + // Edit operation + if (null === $newRecord) { + return $this->canEdit($token, $nodeId); + } + + // Move existing record + $changeSorting = \array_key_exists('sorting', $newRecord); + $changePid = \array_key_exists('pid', $newRecord) && $action->getCurrentPid() !== $action->getNewPid(); + + if (($changeSorting || $changePid) && !$this->canEdit($token, $nodeId)) { + return false; + } + + if ($changePid && !$this->canEdit($token, $action->getNewPid())) { + return false; + } + + unset($newRecord['pid'], $newRecord['sorting'], $newRecord['tstamp']); + + // Record was possibly only moved (pid, sorting), no need to check edit permissions + if ([] === array_diff($newRecord, $action->getCurrent())) { + return true; + } + + return $this->accessDecisionManager->decide($token, [NodePermissions::USER_CAN_EDIT_NODE]); + } + + private function canEdit(TokenInterface $token, string|null $nodeId): bool + { + return $this->accessDecisionManager->decide($token, [NodePermissions::USER_CAN_ACCESS_NODE], $nodeId) + && $this->accessDecisionManager->decide($token, [NodePermissions::USER_CAN_EDIT_NODE]); + } + + private function canDelete(DeleteAction $action, TokenInterface $token): bool + { + return $this->accessDecisionManager->decide($token, [NodePermissions::USER_CAN_ACCESS_NODE], $action->getCurrentId()) + && $this->accessDecisionManager->decide($token, [NodePermissions::USER_CAN_DELETE_NODE]); + } + + private function canAccessNode(TokenInterface $token, string|null $nodeId): bool + { + return $this->accessDecisionManager->decide($token, [NodePermissions::USER_CAN_ACCESS_NODE], $nodeId); + } + + private function getNodeMounts(TokenInterface $token): array + { + $user = $token->getUser(); + + if (!$user instanceof BackendUser) { + return []; + } + + if (isset($this->nodeMountsCache[$user->id])) { + return $this->nodeMountsCache[$user->id]; + } + + $database = $this->contaoFramework->createInstance(Database::class); + + return $this->nodeMountsCache[$user->id] = $database->getChildRecords($user->nodeMounts, 'tl_node', false, $user->nodeMounts); + } +} diff --git a/src/Widget/NodePickerWidget.php b/src/Widget/NodePickerWidget.php index b559ec5..51985ba 100644 --- a/src/Widget/NodePickerWidget.php +++ b/src/Widget/NodePickerWidget.php @@ -35,12 +35,7 @@ class NodePickerWidget extends Widget */ protected $strTemplate = 'be_widget'; - /** - * Generate the widget and return it as string. - * - * @return string - */ - public function generate() + public function generate(): string { $container = System::getContainer(); $values = []; @@ -48,7 +43,7 @@ public function generate() // Can be an array if (!empty($this->varValue) && null !== ($nodes = NodeModel::findMultipleByIds((array) $this->varValue))) { /** @var DataContainerListener $eventListener */ - $eventListener = $container->get('terminal42_node.listener.data_container'); + $eventListener = $container->get(DataContainerListener::class); /** @var NodeModel $node */ foreach ($nodes as $node) { @@ -106,18 +101,15 @@ public function generate() return '
'.$return.'
'; } - /** - * Return an array if the "multiple" attribute is set. - */ - protected function validator($input) + protected function validator(mixed $varInput): array|int|string { - $this->checkValue($input); + $this->checkValue($varInput); if ($this->hasErrors()) { return ''; } - if (!$input) { + if (!$varInput) { if ($this->mandatory) { $this->addError(\sprintf($GLOBALS['TL_LANG']['ERR']['mandatory'], $this->strLabel)); } @@ -125,21 +117,16 @@ protected function validator($input) return ''; } - if (!str_contains($input, ',')) { - return $this->multiple ? [(int) $input] : (int) $input; + if (!str_contains((string) $varInput, ',')) { + return $this->multiple ? [(int) $varInput] : (int) $varInput; } - $value = array_map('intval', array_filter(explode(',', $input))); + $value = array_map('intval', array_filter(explode(',', (string) $varInput))); return $this->multiple ? $value : $value[0]; } - /** - * Check the selected value. - * - * @param string $input - */ - protected function checkValue($input): void + protected function checkValue(string $input): void { if ('' === $input || !\is_array($this->rootNodes)) { return;