diff options
author | Morris Jobke <hey@morrisjobke.de> | 2016-07-26 11:16:34 +0200 |
---|---|---|
committer | Morris Jobke <hey@morrisjobke.de> | 2016-07-26 11:16:34 +0200 |
commit | 2f42a3fc319f4cffcae6db6b0874786f26fa4817 (patch) | |
tree | 080256d466720ae21b0ca1a0804ff15c9b6f3fea | |
parent | cc5ddcf537d03c3f2f4cdc6817e02e098f8e8edb (diff) | |
download | nextcloud-server-2f42a3fc319f4cffcae6db6b0874786f26fa4817.tar.gz nextcloud-server-2f42a3fc319f4cffcae6db6b0874786f26fa4817.zip |
Add workflowengine
-rw-r--r-- | .gitignore | 1 | ||||
-rw-r--r-- | apps/workflowengine/appinfo/app.php | 23 | ||||
-rw-r--r-- | apps/workflowengine/appinfo/database.xml | 90 | ||||
-rw-r--r-- | apps/workflowengine/appinfo/info.xml | 23 | ||||
-rw-r--r-- | apps/workflowengine/appinfo/routes.php | 30 | ||||
-rw-r--r-- | apps/workflowengine/css/admin.css | 43 | ||||
-rw-r--r-- | apps/workflowengine/js/admin.js | 372 | ||||
-rw-r--r-- | apps/workflowengine/js/usergroupmembershipplugin.js | 85 | ||||
-rw-r--r-- | apps/workflowengine/lib/AppInfo/Application.php | 62 | ||||
-rw-r--r-- | apps/workflowengine/lib/Check/UserGroupMembership.php | 108 | ||||
-rw-r--r-- | apps/workflowengine/lib/Controller/FlowOperations.php | 141 | ||||
-rw-r--r-- | apps/workflowengine/lib/Manager.php | 306 | ||||
-rw-r--r-- | core/shipped.json | 6 | ||||
-rw-r--r-- | lib/public/WorkflowEngine/ICheck.php | 56 | ||||
-rw-r--r-- | lib/public/WorkflowEngine/IManager.php | 48 | ||||
-rw-r--r-- | lib/public/WorkflowEngine/RegisterCheckEvent.php | 79 | ||||
-rw-r--r-- | tests/lib/App/ManagerTest.php | 6 |
17 files changed, 1475 insertions, 4 deletions
diff --git a/.gitignore b/.gitignore index f60dda513e4..63a34beb978 100644 --- a/.gitignore +++ b/.gitignore @@ -27,6 +27,7 @@ !/apps/admin_audit !/apps/updatenotification !/apps/theming +!/apps/workflowengine /apps/files_external/3rdparty/irodsphp/PHPUnitTest /apps/files_external/3rdparty/irodsphp/web /apps/files_external/3rdparty/irodsphp/prods/test diff --git a/apps/workflowengine/appinfo/app.php b/apps/workflowengine/appinfo/app.php new file mode 100644 index 00000000000..f6f22ce9488 --- /dev/null +++ b/apps/workflowengine/appinfo/app.php @@ -0,0 +1,23 @@ +<?php +/** + * @copyright Copyright (c) 2016 Morris Jobke <hey@morrisjobke.de> + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + * + */ + +$application = new \OCA\WorkflowEngine\AppInfo\Application(); +$application->registerHooksAndListeners(); diff --git a/apps/workflowengine/appinfo/database.xml b/apps/workflowengine/appinfo/database.xml new file mode 100644 index 00000000000..b67a41faed2 --- /dev/null +++ b/apps/workflowengine/appinfo/database.xml @@ -0,0 +1,90 @@ +<?xml version="1.0" encoding="ISO-8859-1" ?> +<database> + <name>*dbname*</name> + <create>true</create> + <overwrite>false</overwrite> + <charset>utf8</charset> + + <table> + <name>*dbprefix*flow_checks</name> + <declaration> + <field> + <name>id</name> + <type>integer</type> + <default>0</default> + <notnull>true</notnull> + <autoincrement>1</autoincrement> + <length>4</length> + </field> + + <field> + <name>class</name> + <type>text</type> + <notnull>true</notnull> + <length>256</length> + </field> + <field> + <name>operator</name> + <type>text</type> + <notnull>true</notnull> + <length>16</length> + </field> + <field> + <name>value</name> + <type>clob</type> + <notnull>false</notnull> + </field> + <field> + <name>hash</name> + <type>text</type> + <notnull>true</notnull> + <length>32</length> + </field> + + <index> + <name>flow_unique_hash</name> + <unique>true</unique> + <field> + <name>hash</name> + </field> + </index> + </declaration> + </table> + + <table> + <name>*dbprefix*flow_operations</name> + <declaration> + <field> + <name>id</name> + <type>integer</type> + <default>0</default> + <notnull>true</notnull> + <autoincrement>1</autoincrement> + <length>4</length> + </field> + + <field> + <name>class</name> + <type>text</type> + <notnull>true</notnull> + <length>256</length> + </field> + <field> + <name>name</name> + <type>text</type> + <notnull>true</notnull> + <length>256</length> + </field> + <field> + <name>checks</name> + <type>clob</type> + <notnull>false</notnull> + </field> + <field> + <name>operation</name> + <type>clob</type> + <notnull>false</notnull> + </field> + </declaration> + </table> +</database> diff --git a/apps/workflowengine/appinfo/info.xml b/apps/workflowengine/appinfo/info.xml new file mode 100644 index 00000000000..066589c6618 --- /dev/null +++ b/apps/workflowengine/appinfo/info.xml @@ -0,0 +1,23 @@ +<?xml version="1.0"?> +<info> + <id>workflowengine</id> + <name>Files Workflow Engine</name> + <description></description> + <licence>AGPL</licence> + <author>Morris Jobke</author> + <version>1.0.0</version> + <namespace>WorkflowEngine</namespace> + + <category>other</category> + <website>https://github.com/nextcloud/server</website> + <bugs>https://github.com/nextcloud/server/issues</bugs> + <repository type="git">https://github.com/nextcloud/server.git</repository> + + <types> + <filesystem/> + </types> + + <dependencies> + <owncloud min-version="9.2" max-version="9.2" /> + </dependencies> +</info> diff --git a/apps/workflowengine/appinfo/routes.php b/apps/workflowengine/appinfo/routes.php new file mode 100644 index 00000000000..69478b1715c --- /dev/null +++ b/apps/workflowengine/appinfo/routes.php @@ -0,0 +1,30 @@ +<?php +/** + * @copyright Copyright (c) 2016 Morris Jobke <hey@morrisjobke.de> + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + * + */ + +return [ + 'routes' => [ + ['name' => 'flowOperations#getChecks', 'url' => '/checks', 'verb' => 'GET'], // TODO rm and do via js? + ['name' => 'flowOperations#getOperations', 'url' => '/operations', 'verb' => 'GET'], + ['name' => 'flowOperations#addOperation', 'url' => '/operations', 'verb' => 'POST'], + ['name' => 'flowOperations#updateOperation', 'url' => '/operations/{id}', 'verb' => 'PUT'], + ['name' => 'flowOperations#deleteOperation', 'url' => '/operations/{id}', 'verb' => 'DELETE'], + ] +]; diff --git a/apps/workflowengine/css/admin.css b/apps/workflowengine/css/admin.css new file mode 100644 index 00000000000..73ac448cd7b --- /dev/null +++ b/apps/workflowengine/css/admin.css @@ -0,0 +1,43 @@ +.workflowengine .operation { + padding: 5px; + border-bottom: #eee 1px solid; + border-left: rgba(0,0,0,0) 1px solid; +} +.workflowengine .operation.modified { + border-left: rgb(255, 94, 32) 1px solid; +} +.workflowengine .operation button { + margin-bottom: 0; +} +.workflowengine .operation span.info { + padding: 7px; + color: #eee; +} +.workflowengine .rules .operation:nth-last-child(2) { + margin-bottom: 5px; +} + +.workflowengine .pull-right { + float: right +} + +.workflowengine .operation .msg { + border-radius: 3px; + margin: 3px 3px 3px 0; + padding: 5px; + transition: opacity .5s; +} + +.workflowengine .operation .button-delete, +.workflowengine .operation .button-delete-check { + opacity: 0.5; + padding: 7px; +} +.workflowengine .operation .button-delete:hover, +.workflowengine .operation .button-delete:focus, +.workflowengine .operation .button-delete-check:hover, +.workflowengine .operation .button-delete-check:focus { + opacity: 1; + cursor: pointer; +} + diff --git a/apps/workflowengine/js/admin.js b/apps/workflowengine/js/admin.js new file mode 100644 index 00000000000..a29ad82ab89 --- /dev/null +++ b/apps/workflowengine/js/admin.js @@ -0,0 +1,372 @@ +/** + * @copyright Copyright (c) 2016 Morris Jobke <hey@morrisjobke.de> + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + * + */ + +(function() { + Handlebars.registerHelper('selectItem', function(currentValue, itemValue) { + if(currentValue === itemValue) { + return 'selected=selected'; + } + + return ""; + }); + + Handlebars.registerHelper('getOperators', function(classname) { + return OCA.WorkflowEngine.availableChecks + .getOperatorsByClassName(classname); + }); + + OCA.WorkflowEngine = OCA.WorkflowEngine || {}; + + /** + * 888b d888 888 888 + * 8888b d8888 888 888 + * 88888b.d88888 888 888 + * 888Y88888P888 .d88b. .d88888 .d88b. 888 .d8888b + * 888 Y888P 888 d88""88b d88" 888 d8P Y8b 888 88K + * 888 Y8P 888 888 888 888 888 88888888 888 "Y8888b. + * 888 " 888 Y88..88P Y88b 888 Y8b. 888 X88 + * 888 888 "Y88P" "Y88888 "Y8888 888 88888P' + */ + + /** + * @class OCA.WorkflowEngine.Operation + */ + OCA.WorkflowEngine.Operation = + OC.Backbone.Model.extend({ + defaults: { + 'class': 'OCA\\WorkflowEngine\\Operation', + 'name': '', + 'checks': [], + 'operation': '' + } + }); + + /** + * @class OCA.WorkflowEngine.AvailableCheck + */ + OCA.WorkflowEngine.AvailableCheck = + OC.Backbone.Model.extend({}); + + /** + * .d8888b. 888 888 888 d8b + * d88P Y88b 888 888 888 Y8P + * 888 888 888 888 888 + * 888 .d88b. 888 888 .d88b. .d8888b 888888 888 .d88b. 88888b. .d8888b + * 888 d88""88b 888 888 d8P Y8b d88P" 888 888 d88""88b 888 "88b 88K + * 888 888 888 888 888 888 88888888 888 888 888 888 888 888 888 "Y8888b. + * Y88b d88P Y88..88P 888 888 Y8b. Y88b. Y88b. 888 Y88..88P 888 888 X88 + * "Y8888P" "Y88P" 888 888 "Y8888 "Y8888P "Y888 888 "Y88P" 888 888 88888P' + */ + + /** + * @class OCA.WorkflowEngine.OperationsCollection + * + * collection for all configurated operations + */ + OCA.WorkflowEngine.OperationsCollection = + OC.Backbone.Collection.extend({ + model: OCA.WorkflowEngine.Operation, + url: OC.generateUrl('apps/workflowengine/operations') + }); + + /** + * @class OCA.WorkflowEngine.AvailableChecksCollection + * + * collection for all available checks + */ + OCA.WorkflowEngine.AvailableChecksCollection = + OC.Backbone.Collection.extend({ + model: OCA.WorkflowEngine.AvailableCheck, + url: OC.generateUrl('apps/workflowengine/checks'), + getOperatorsByClassName: function(classname) { + return OCA.WorkflowEngine.availableChecks + .findWhere({'class': classname}) + .get('operators'); + } + }); + + /** + * 888 888 d8b + * 888 888 Y8P + * 888 888 + * Y88b d88P 888 .d88b. 888 888 888 .d8888b + * Y88b d88P 888 d8P Y8b 888 888 888 88K + * Y88o88P 888 88888888 888 888 888 "Y8888b. + * Y888P 888 Y8b. Y88b 888 d88P X88 + * Y8P 888 "Y8888 "Y8888888P" 88888P' + */ + + /** + * @class OCA.WorkflowEngine.TemplateView + * + * a generic template that handles the Handlebars template compile step + * in a method called "template()" + */ + OCA.WorkflowEngine.TemplateView = + OC.Backbone.View.extend({ + _template: null, + template: function(vars) { + if (!this._template) { + this._template = Handlebars.compile($(this.templateId).html()); + } + return this._template(vars); + } + }); + + /** + * @class OCA.WorkflowEngine.OperationView + * + * this creates the view for a single operation + */ + OCA.WorkflowEngine.OperationView = + OCA.WorkflowEngine.TemplateView.extend({ + templateId: '#operation-template', + events: { + 'change .check-class': 'checkChanged', + 'change .check-operator': 'checkChanged', + 'change .check-value': 'checkChanged', + 'change .operation-name': 'operationChanged', + 'click .button-reset': 'reset', + 'click .button-save': 'save', + 'click .button-add': 'add', + 'click .button-delete': 'delete', + 'click .button-delete-check': 'deleteCheck' + }, + originalModel: null, + hasChanged: false, + message: '', + errorMessage: '', + saving: false, + plugins: [], + initialize: function() { + // this creates a new copy of the object to definitely have a new reference and being able to reset the model + this.originalModel = JSON.parse(JSON.stringify(this.model)); + this.model.on('change', function(){ + console.log('model changed'); + this.hasChanged = true; + this.render(); + }, this); + + if (this.model.get('id') === undefined) { + this.hasChanged = true; + } + + this.plugins = OC.Plugins.getPlugins('OCA.WorkflowEngine.CheckPlugins'); + _.each(this.plugins, function(plugin) { + if (_.isFunction(plugin.initialize)) { + plugin.initialize(); + } + }); + }, + delete: function() { + this.model.destroy(); + this.remove(); + }, + reset: function() { + this.hasChanged = false; + // silent is need to not trigger the change event which resets the hasChanged attribute + this.model.set(this.originalModel, {silent: true}); + this.render(); + }, + save: function() { + var success = function(model, response, options) { + this.saving = false; + this.originalModel = JSON.parse(JSON.stringify(this.model)); + + this.message = t('workflowengine', 'Successfully saved'); + this.errorMessage = ''; + this.render(); + }; + var error = function(model, response, options) { + this.saving = false; + this.hasChanged = true; + + this.message = t('workflowengine', 'Saving failed:'); + this.errorMessage = response.responseText; + this.render(); + }; + this.hasChanged = false; + this.saving = true; + this.render(); + this.model.save(null, {success: success, error: error, context: this}); + }, + add: function() { + var checks = _.clone(this.model.get('checks')), + classname = OCA.WorkflowEngine.availableChecks.at(0).get('class'), + operators = OCA.WorkflowEngine.availableChecks + .getOperatorsByClassName(classname); + + checks.push({ + 'class': classname, + 'operator': operators[0], + 'value': '' + }); + this.model.set({'checks': checks}); + }, + checkChanged: function(event) { + var value = event.target.value, + id = $(event.target.parentElement).data('id'), + // this creates a new copy of the object to definitely have a new reference + checks = JSON.parse(JSON.stringify(this.model.get('checks'))), + key = null; + + for (var i = 0; i < event.target.classList.length; i++) { + var className = event.target.classList[i]; + if (className.substr(0, 'check-'.length) === 'check-') { + key = className.substr('check-'.length); + break; + } + } + + if (key === null) { + console.warn('checkChanged triggered but element doesn\'t have any "check-" class'); + return; + } + + if (!_.has(checks[id], key)) { + console.warn('key "' + key + '" is not available in check', check); + return; + } + + checks[id][key] = value; + // if the class is changed most likely also the operators have changed + // with this we set the operator to the first possible operator + if (key === 'class') { + var operators = OCA.WorkflowEngine.availableChecks + .getOperatorsByClassName(value); + checks[id]['operator'] = operators[0]; + } + // model change will trigger render + this.model.set({'checks': checks}); + }, + deleteCheck: function() { + console.log(arguments); + var id = $(event.target.parentElement).data('id'), + checks = JSON.parse(JSON.stringify(this.model.get('checks'))); + + // splice removes 1 element at index `id` + checks.splice(id, 1); + // model change will trigger render + this.model.set({'checks': checks}); + }, + operationChanged: function(event) { + var value = event.target.value, + key = null; + + for (var i = 0; i < event.target.classList.length; i++) { + var className = event.target.classList[i]; + if (className.substr(0, 'operation-'.length) === 'operation-') { + key = className.substr('operation-'.length); + break; + } + } + + if (key === null) { + console.warn('operationChanged triggered but element doesn\'t have any "operation-" class'); + return; + } + + if (key !== 'name') { + console.warn('key "' + key + '" is no valid attribute'); + return; + } + + // model change will trigger render + this.model.set(key, value); + }, + render: function() { + this.$el.html(this.template({ + operation: this.model.toJSON(), + classes: OCA.WorkflowEngine.availableChecks.toJSON(), + hasChanged: this.hasChanged, + message: this.message, + errorMessage: this.errorMessage, + saving: this.saving + })); + + var checks = this.model.get('checks'); + _.each(this.$el.find('.check'), function(element){ + var $element = $(element), + id = $element.data('id'), + check = checks[id], + valueElement = $element.find('.check-value').first(); + + _.each(this.plugins, function(plugin) { + if (_.isFunction(plugin.render)) { + plugin.render(valueElement, check['class'], check['value']); + } + }); + }, this); + + if (this.message !== '') { + // hide success messages after some time + _.delay(function(elements){ + $(elements).css('opacity', 0); + }, 7000, this.$el.find('.msg.success')); + this.message = ''; + } + + } + }); + + /** + * @class OCA.WorkflowEngine.OperationsView + * + * this creates the view for configured operations + */ + OCA.WorkflowEngine.OperationsView = + OCA.WorkflowEngine.TemplateView.extend({ + templateId: '#operations-template', + events: { + 'click .button-add-operation': 'add' + }, + initialize: function() { + this._initialize('OCA\\WorkflowEngine\\Operation'); + }, + _initialize: function(classname) { + var data = {}; + if (this.operationsClass !== null) { + data['class'] = this.operationsClass; + } + this.collection.fetch({data: { + 'class': classname + }}); + this.collection.once('sync', this.render, this); + }, + add: function() { + var operation = new OCA.WorkflowEngine.Operation(); + this.collection.add(operation); + this.renderOperation(operation); + }, + renderOperation: function(operation){ + console.log(operation); + var subView = new OCA.WorkflowEngine.OperationView({ + model: operation + }), + operationsElement = this.$el.find('.operations'); + operationsElement.append(subView.$el); + subView.render(); + }, + render: function() { + this.$el.html(this.template()); + this.collection.each(this.renderOperation, this); + } + }); +})(); diff --git a/apps/workflowengine/js/usergroupmembershipplugin.js b/apps/workflowengine/js/usergroupmembershipplugin.js new file mode 100644 index 00000000000..2a6068cda90 --- /dev/null +++ b/apps/workflowengine/js/usergroupmembershipplugin.js @@ -0,0 +1,85 @@ +/** + * @copyright Copyright (c) 2016 Morris Jobke <hey@morrisjobke.de> + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + * + */ + +(function() { + + OCA.WorkflowEngine = OCA.WorkflowEngine || {}; + OCA.WorkflowEngine.Plugins = OCA.WorkflowEngine.Plugins || {}; + + OCA.WorkflowEngine.Plugins.UserGroupMembershipPlugin = { + render: function(element, classname, value) { + if (classname !== 'OCA\\WorkflowEngine\\Check\\UserGroupMembership') { + return; + } + + $(element).css('width', '400px'); + + $(element).select2({ + ajax: { + url: OC.generateUrl('settings/users/groups'), + dataType: 'json', + quietMillis: 100, + data: function (term) { + return { + pattern: term, //search term + filterGroups: true, + sortGroups: 2 // by groupname + }; + }, + results: function (response) { + // TODO improve error case + if (response.data === undefined) { + console.error('Failure happened', response); + return; + } + + var results = []; + + // add admin groups + $.each(response.data.adminGroups, function(id, group) { + results.push({ id: group.id }); + }); + // add groups + $.each(response.data.groups, function(id, group) { + results.push({ id: group.id }); + }); + + // TODO once limit and offset is implemented for groups we should paginate the search results + return { + results: results, + more: false + }; + } + }, + initSelection: function (element, callback) { + callback({id: element.val()}); + }, + formatResult: function (element) { + return '<span>' + escapeHTML(element.id) + '</span>'; + }, + formatSelection: function (element) { + return '<span title="'+escapeHTML(element.id)+'">'+escapeHTML(element.id)+'</span>'; + } + }); + } + }; +})(); + +OC.Plugins.register('OCA.WorkflowEngine.CheckPlugins', OCA.WorkflowEngine.Plugins.UserGroupMembershipPlugin); diff --git a/apps/workflowengine/lib/AppInfo/Application.php b/apps/workflowengine/lib/AppInfo/Application.php new file mode 100644 index 00000000000..c196ecd955c --- /dev/null +++ b/apps/workflowengine/lib/AppInfo/Application.php @@ -0,0 +1,62 @@ +<?php +/** + * @copyright Copyright (c) 2016 Morris Jobke <hey@morrisjobke.de> + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + * + */ + +namespace OCA\WorkflowEngine\AppInfo; + +use OCP\Util; +use OCP\WorkflowEngine\RegisterCheckEvent; + +class Application extends \OCP\AppFramework\App { + + public function __construct() { + parent::__construct('workflowengine'); + + $this->getContainer()->registerAlias('FlowOperationsController', 'OCA\WorkflowEngine\Controller\FlowOperations'); + } + + /** + * Register all hooks and listeners + */ + public function registerHooksAndListeners() { + $dispatcher = $this->getContainer()->getServer()->getEventDispatcher(); + $dispatcher->addListener( + 'OCP\WorkflowEngine\RegisterCheckEvent', + function(RegisterCheckEvent $event) { + $event->addCheck( + 'OCA\WorkflowEngine\Check\UserGroupMembership', + 'User group membership', + ['is', '!is'] + ); + }, + -100 + ); + + $dispatcher->addListener( + 'OCP\WorkflowEngine::loadAdditionalSettingScripts', + function() { + Util::addStyle('workflowengine', 'admin'); + Util::addScript('workflowengine', 'admin'); + Util::addScript('workflowengine', 'usergroupmembershipplugin'); + }, + -100 + ); + } +} diff --git a/apps/workflowengine/lib/Check/UserGroupMembership.php b/apps/workflowengine/lib/Check/UserGroupMembership.php new file mode 100644 index 00000000000..f437dbfc2d1 --- /dev/null +++ b/apps/workflowengine/lib/Check/UserGroupMembership.php @@ -0,0 +1,108 @@ +<?php +/** + * @copyright Copyright (c) 2016 Morris Jobke <hey@morrisjobke.de> + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + * + */ + +namespace OCA\WorkflowEngine\Check; + + +use OCP\Files\Storage\IStorage; +use OCP\IGroupManager; +use OCP\IUser; +use OCP\IUserSession; +use OCP\WorkflowEngine\ICheck; + +class UserGroupMembership implements ICheck { + + /** @var string */ + protected $cachedUser; + + /** @var string[] */ + protected $cachedGroupMemberships; + + /** @var IUserSession */ + protected $userSession; + + /** @var IGroupManager */ + protected $groupManager; + + /** + * @param IUserSession $userSession + * @param IGroupManager $groupManager + */ + public function __construct(IUserSession $userSession, IGroupManager $groupManager) { + $this->userSession = $userSession; + $this->groupManager = $groupManager; + } + + /** + * @param IStorage $storage + * @param string $path + */ + public function setFileInfo(IStorage $storage, $path) { + // A different path doesn't change group memberships, so nothing to do here. + } + + /** + * @param string $operator + * @param string $value + * @return bool + */ + public function executeCheck($operator, $value) { + $user = $this->userSession->getUser(); + + if ($user instanceof IUser) { + $groupIds = $this->getUserGroups($user); + return ($operator === 'is') === in_array($value, $groupIds); + } else { + return $operator !== 'is'; + } + } + + + /** + * @param string $operator + * @param string $value + * @throws \UnexpectedValueException + */ + public function validateCheck($operator, $value) { + if (!in_array($operator, ['is', '!is'])) { + throw new \UnexpectedValueException('Invalid operator', 1); + } + + if (!$this->groupManager->groupExists($value)) { + throw new \UnexpectedValueException('Group does not exist', 2); + } + } + + /** + * @param IUser $user + * @return string[] + */ + protected function getUserGroups(IUser $user) { + $uid = $user->getUID(); + + if ($this->cachedUser !== $uid) { + $this->cachedUser = $uid; + $this->cachedGroupMemberships = $this->groupManager->getUserGroupIds($user); + } + + return $this->cachedGroupMemberships; + } +} diff --git a/apps/workflowengine/lib/Controller/FlowOperations.php b/apps/workflowengine/lib/Controller/FlowOperations.php new file mode 100644 index 00000000000..e0836c727a2 --- /dev/null +++ b/apps/workflowengine/lib/Controller/FlowOperations.php @@ -0,0 +1,141 @@ +<?php +/** + * @copyright Copyright (c) 2016 Morris Jobke <hey@morrisjobke.de> + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + * + */ + +namespace OCA\WorkflowEngine\Controller; + +use OCA\WorkflowEngine\Manager; +use OCP\AppFramework\Controller; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\JSONResponse; +use OCP\IRequest; +use OCP\WorkflowEngine\RegisterCheckEvent; +use Symfony\Component\EventDispatcher\EventDispatcherInterface; + +class FlowOperations extends Controller { + + /** @var Manager */ + protected $manager; + + /** @var EventDispatcherInterface */ + protected $dispatcher; + + /** + * @param IRequest $request + * @param Manager $manager + * @param EventDispatcherInterface $dispatcher + */ + public function __construct(IRequest $request, Manager $manager, EventDispatcherInterface $dispatcher) { + parent::__construct('workflowengine', $request); + $this->manager = $manager; + $this->dispatcher = $dispatcher; + } + + /** + * @NoCSRFRequired + * + * @return JSONResponse + */ + public function getChecks() { + $event = new RegisterCheckEvent(); + $this->dispatcher->dispatch('OCP\WorkflowEngine\RegisterCheckEvent', $event); + + return new JSONResponse($event->getChecks()); + } + + /** + * @NoCSRFRequired + * + * @param string $class + * @return JSONResponse + */ + public function getOperations($class) { + $operations = $this->manager->getOperations($class); + + foreach ($operations as &$operation) { + $operation = $this->prepareOperation($operation); + } + + return new JSONResponse($operations); + } + + /** + * @param string $class + * @param string $name + * @param array[] $checks + * @param string $operation + * @return JSONResponse The added element + */ + public function addOperation($class, $name, $checks, $operation) { + try { + $operation = $this->manager->addOperation($class, $name, $checks, $operation); + $operation = $this->prepareOperation($operation); + return new JSONResponse($operation); + } catch (\UnexpectedValueException $e) { + return new JSONResponse($e->getMessage(), Http::STATUS_BAD_REQUEST); + } + } + + /** + * @param int $id + * @param string $name + * @param array[] $checks + * @param string $operation + * @return JSONResponse The updated element + */ + public function updateOperation($id, $name, $checks, $operation) { + try { + $operation = $this->manager->updateOperation($id, $name, $checks, $operation); + $operation = $this->prepareOperation($operation); + return new JSONResponse($operation); + } catch (\UnexpectedValueException $e) { + return new JSONResponse($e->getMessage(), Http::STATUS_BAD_REQUEST); + } + } + + /** + * @param int $id + * @return JSONResponse + */ + public function deleteOperation($id) { + $deleted = $this->manager->deleteOperation((int) $id); + return new JSONResponse($deleted); + } + + /** + * @param array $operation + * @return array + */ + protected function prepareOperation(array $operation) { + $checkIds = json_decode($operation['checks']); + $checks = $this->manager->getChecks($checkIds); + + $operation['checks'] = []; + foreach ($checks as $check) { + // Remove internal values + unset($check['id']); + unset($check['hash']); + + $operation['checks'][] = $check; + } + + return $operation; + } +} diff --git a/apps/workflowengine/lib/Manager.php b/apps/workflowengine/lib/Manager.php new file mode 100644 index 00000000000..98c34e894cc --- /dev/null +++ b/apps/workflowengine/lib/Manager.php @@ -0,0 +1,306 @@ +<?php +/** + * @copyright Copyright (c) 2016 Morris Jobke <hey@morrisjobke.de> + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + * + */ + +namespace OCA\WorkflowEngine; + + +use OCP\AppFramework\QueryException; +use OCP\DB\QueryBuilder\IQueryBuilder; +use OCP\Files\Storage\IStorage; +use OCP\IDBConnection; +use OCP\IServerContainer; +use OCP\WorkflowEngine\ICheck; +use OCP\WorkflowEngine\IManager; + +class Manager implements IManager { + + /** @var IStorage */ + protected $storage; + + /** @var string */ + protected $path; + + /** @var array[] */ + protected $operations = []; + + /** @var array[] */ + protected $checks = []; + + /** @var IDBConnection */ + protected $connection; + + /** @var IServerContainer|\OC\Server */ + protected $container; + + /** + * @param IDBConnection $connection + * @param IServerContainer $container + */ + public function __construct(IDBConnection $connection, IServerContainer $container) { + $this->connection = $connection; + $this->container = $container; + } + + /** + * @inheritdoc + */ + public function setFileInfo(IStorage $storage, $path) { + $this->storage = $storage; + $this->path = $path; + } + + /** + * @inheritdoc + */ + public function getMatchingOperations($class, $returnFirstMatchingOperationOnly = true) { + $operations = $this->getOperations($class); + + $matches = []; + foreach ($operations as $operation) { + $checkIds = json_decode($operation['checks'], true); + $checks = $this->getChecks($checkIds); + + foreach ($checks as $check) { + if (!$this->check($check)) { + // Check did not match, continue with the next operation + continue 2; + } + } + + if ($returnFirstMatchingOperationOnly) { + return $operation; + } + $matches[] = $operation; + } + + return $matches; + } + + /** + * @param array $check + * @return bool + */ + protected function check(array $check) { + try { + $checkInstance = $this->container->query($check['class']); + } catch (QueryException $e) { + // Check does not exist, assume it matches. + return true; + } + + if ($checkInstance instanceof ICheck) { + $checkInstance->setFileInfo($this->storage, $this->path); + return $checkInstance->executeCheck($check['operator'], $check['value']); + } else { + // Check is invalid, assume it matches. + return true; + } + } + + /** + * @param string $class + * @return array[] + */ + public function getOperations($class) { + if (isset($this->operations[$class])) { + return $this->operations[$class]; + } + + $query = $this->connection->getQueryBuilder(); + + $query->select('*') + ->from('flow_operations') + ->where($query->expr()->eq('class', $query->createNamedParameter($class))); + $result = $query->execute(); + + $this->operations[$class] = []; + while ($row = $result->fetch()) { + $this->operations[$class][] = $row; + } + $result->closeCursor(); + + return $this->operations[$class]; + } + + /** + * @param int $id + * @return array + * @throws \UnexpectedValueException + */ + protected function getOperation($id) { + $query = $this->connection->getQueryBuilder(); + $query->select('*') + ->from('flow_operations') + ->where($query->expr()->eq('id', $query->createNamedParameter($id))); + $result = $query->execute(); + $row = $result->fetch(); + $result->closeCursor(); + + if ($row) { + return $row; + } + + throw new \UnexpectedValueException('Operation does not exist'); + } + + /** + * @param string $class + * @param string $name + * @param array[] $checks + * @param string $operation + * @return array The added operation + * @throws \UnexpectedValueException + */ + public function addOperation($class, $name, array $checks, $operation) { + $checkIds = []; + foreach ($checks as $check) { + $checkIds[] = $this->addCheck($check['class'], $check['operator'], $check['value']); + } + + $query = $this->connection->getQueryBuilder(); + $query->insert('flow_operations') + ->values([ + 'class' => $query->createNamedParameter($class), + 'name' => $query->createNamedParameter($name), + 'checks' => $query->createNamedParameter(json_encode(array_unique($checkIds))), + 'operation' => $query->createNamedParameter($operation), + ]); + $query->execute(); + + $id = $query->getLastInsertId(); + return $this->getOperation($id); + } + + /** + * @param int $id + * @param string $name + * @param array[] $checks + * @param string $operation + * @return array The updated operation + * @throws \UnexpectedValueException + */ + public function updateOperation($id, $name, array $checks, $operation) { + $checkIds = []; + foreach ($checks as $check) { + $checkIds[] = $this->addCheck($check['class'], $check['operator'], $check['value']); + } + + $query = $this->connection->getQueryBuilder(); + $query->update('flow_operations') + ->set('name', $query->createNamedParameter($name)) + ->set('checks', $query->createNamedParameter(json_encode(array_unique($checkIds)))) + ->set('operation', $query->createNamedParameter($operation)) + ->where($query->expr()->eq('id', $query->createNamedParameter($id))); + $query->execute(); + + return $this->getOperation($id); + } + + /** + * @param int $id + * @return bool + * @throws \UnexpectedValueException + */ + public function deleteOperation($id) { + $query = $this->connection->getQueryBuilder(); + $query->delete('flow_operations') + ->where($query->expr()->eq('id', $query->createNamedParameter($id))); + return (bool) $query->execute(); + } + + /** + * @param int[] $checkIds + * @return array[] + */ + public function getChecks(array $checkIds) { + $checkIds = array_map('intval', $checkIds); + + $checks = []; + foreach ($checkIds as $i => $checkId) { + if (isset($this->checks[$checkId])) { + $checks[$checkId] = $this->checks[$checkId]; + unset($checkIds[$i]); + } + } + + if (empty($checkIds)) { + return $checks; + } + + $query = $this->connection->getQueryBuilder(); + $query->select('*') + ->from('flow_checks') + ->where($query->expr()->in('id', $query->createNamedParameter($checkIds, IQueryBuilder::PARAM_INT_ARRAY))); + $result = $query->execute(); + + $checks = []; + while ($row = $result->fetch()) { + $this->checks[(int) $row['id']] = $row; + $checks[(int) $row['id']] = $row; + } + $result->closeCursor(); + + // TODO What if a check is missing? Should we throw? + // As long as we only allow AND-concatenation of checks, a missing check + // is like a matching check, so it evaluates to true and therefor blocks + // access. So better save than sorry. + + return $checks; + } + + /** + * @param string $class + * @param string $operator + * @param string $value + * @return int Check unique ID + * @throws \UnexpectedValueException + */ + protected function addCheck($class, $operator, $value) { + /** @var ICheck $check */ + $check = $this->container->query($class); + $check->validateCheck($operator, $value); + + $hash = md5($class . '::' . $operator . '::' . $value); + + $query = $this->connection->getQueryBuilder(); + $query->select('id') + ->from('flow_checks') + ->where($query->expr()->eq('hash', $query->createNamedParameter($hash))); + $result = $query->execute(); + + if ($row = $result->fetch()) { + $result->closeCursor(); + return (int) $row['id']; + } + + $query = $this->connection->getQueryBuilder(); + $query->insert('flow_checks') + ->values([ + 'class' => $query->createNamedParameter($class), + 'operator' => $query->createNamedParameter($operator), + 'value' => $query->createNamedParameter($value), + 'hash' => $query->createNamedParameter($hash), + ]); + $query->execute(); + + return $query->getLastInsertId(); + } +} diff --git a/core/shipped.json b/core/shipped.json index da48d14dc0b..8d3056eb908 100644 --- a/core/shipped.json +++ b/core/shipped.json @@ -27,11 +27,13 @@ "updatenotification", "user_external", "user_ldap", - "user_saml" + "user_saml", + "workflowengine" ], "alwaysEnabled": [ "files", "dav", - "federatedfilesharing" + "federatedfilesharing", + "workflowengine" ] } diff --git a/lib/public/WorkflowEngine/ICheck.php b/lib/public/WorkflowEngine/ICheck.php new file mode 100644 index 00000000000..7e3d86caad9 --- /dev/null +++ b/lib/public/WorkflowEngine/ICheck.php @@ -0,0 +1,56 @@ +<?php +/** + * @copyright Copyright (c) 2016 Morris Jobke <hey@morrisjobke.de> + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + * + */ + +namespace OCP\WorkflowEngine; + + +use OCP\Files\Storage\IStorage; + +/** + * Interface ICheck + * + * @package OCP\WorkflowEngine + * @since 9.1 + */ +interface ICheck { + /** + * @param IStorage $storage + * @param string $path + * @since 9.1 + */ + public function setFileInfo(IStorage $storage, $path); + + /** + * @param string $operator + * @param string $value + * @return bool + * @since 9.1 + */ + public function executeCheck($operator, $value); + + /** + * @param string $operator + * @param string $value + * @throws \UnexpectedValueException + * @since 9.1 + */ + public function validateCheck($operator, $value); +} diff --git a/lib/public/WorkflowEngine/IManager.php b/lib/public/WorkflowEngine/IManager.php new file mode 100644 index 00000000000..e53a06ec929 --- /dev/null +++ b/lib/public/WorkflowEngine/IManager.php @@ -0,0 +1,48 @@ +<?php +/** + * @copyright Copyright (c) 2016 Morris Jobke <hey@morrisjobke.de> + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + * + */ + +namespace OCP\WorkflowEngine; + + +use OCP\Files\Storage\IStorage; + +/** + * Interface IManager + * + * @package OCP\WorkflowEngine + * @since 9.1 + */ +interface IManager { + /** + * @param IStorage $storage + * @param string $path + * @since 9.1 + */ + public function setFileInfo(IStorage $storage, $path); + + /** + * @param string $class + * @param bool $returnFirstMatchingOperationOnly + * @return array + * @since 9.1 + */ + public function getMatchingOperations($class, $returnFirstMatchingOperationOnly = true); +} diff --git a/lib/public/WorkflowEngine/RegisterCheckEvent.php b/lib/public/WorkflowEngine/RegisterCheckEvent.php new file mode 100644 index 00000000000..e08aae5fbc0 --- /dev/null +++ b/lib/public/WorkflowEngine/RegisterCheckEvent.php @@ -0,0 +1,79 @@ +<?php +/** + * @copyright Copyright (c) 2016 Morris Jobke <hey@morrisjobke.de> + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + * + */ + +namespace OCP\WorkflowEngine; + + +use Symfony\Component\EventDispatcher\Event; + +/** + * Class RegisterCheckEvent + * + * @package OCP\WorkflowEngine + * @since 9.1 + */ +class RegisterCheckEvent extends Event { + + /** @var array[] */ + protected $checks = []; + + /** + * @param string $class + * @param string $name + * @param string[] $operators + * @throws \OutOfBoundsException when the check class is already registered + * @throws \OutOfBoundsException when the provided information is invalid + * @since 9.1 + */ + public function addCheck($class, $name, array $operators) { + if (!is_string($class)) { + throw new \OutOfBoundsException('Given class name is not a string'); + } + + if (isset($this->checks[$class])) { + throw new \OutOfBoundsException('Duplicate check class "' . $class . '"'); + } + + if (!is_string($name)) { + throw new \OutOfBoundsException('Given check name is not a string'); + } + + foreach ($operators as $operator) { + if (!is_string($operator)) { + throw new \OutOfBoundsException('At least one of the operators is not a string'); + } + } + + $this->checks[$class] = [ + 'class' => $class, + 'name' => $name, + 'operators' => $operators, + ]; + } + + /** + * @return array[] + * @since 9.1 + */ + public function getChecks() { + return array_values($this->checks); + } +} diff --git a/tests/lib/App/ManagerTest.php b/tests/lib/App/ManagerTest.php index 2d4ec4968b0..80754413fc8 100644 --- a/tests/lib/App/ManagerTest.php +++ b/tests/lib/App/ManagerTest.php @@ -306,7 +306,7 @@ class ManagerTest extends TestCase { $this->appConfig->setValue('test1', 'enabled', 'yes'); $this->appConfig->setValue('test2', 'enabled', 'no'); $this->appConfig->setValue('test3', 'enabled', '["foo"]'); - $this->assertEquals(['dav', 'federatedfilesharing', 'files', 'test1', 'test3'], $this->manager->getInstalledApps()); + $this->assertEquals(['dav', 'federatedfilesharing', 'files', 'test1', 'test3', 'workflowengine'], $this->manager->getInstalledApps()); } public function testGetAppsForUser() { @@ -320,7 +320,7 @@ class ManagerTest extends TestCase { $this->appConfig->setValue('test2', 'enabled', 'no'); $this->appConfig->setValue('test3', 'enabled', '["foo"]'); $this->appConfig->setValue('test4', 'enabled', '["asd"]'); - $this->assertEquals(['dav', 'federatedfilesharing', 'files', 'test1', 'test3'], $this->manager->getEnabledAppsForUser($user)); + $this->assertEquals(['dav', 'federatedfilesharing', 'files', 'test1', 'test3', 'workflowengine'], $this->manager->getEnabledAppsForUser($user)); } public function testGetAppsNeedingUpgrade() { @@ -338,6 +338,7 @@ class ManagerTest extends TestCase { 'test3' => ['id' => 'test3', 'version' => '1.2.4', 'requiremin' => '9.0.0'], 'test4' => ['id' => 'test4', 'version' => '3.0.0', 'requiremin' => '8.1.0'], 'testnoversion' => ['id' => 'testnoversion', 'requiremin' => '8.2.0'], + 'workflowengine' => ['id' => 'workflowengine'], ]; $this->manager->expects($this->any()) @@ -378,6 +379,7 @@ class ManagerTest extends TestCase { 'test2' => ['id' => 'test2', 'version' => '1.0.0', 'requiremin' => '8.2.0'], 'test3' => ['id' => 'test3', 'version' => '1.2.4', 'requiremin' => '9.0.0'], 'testnoversion' => ['id' => 'testnoversion', 'requiremin' => '8.2.0'], + 'workflowengine' => ['id' => 'workflowengine'], ]; $this->manager->expects($this->any()) |