diff options
Diffstat (limited to 'apps/workflowengine/lib')
34 files changed, 2666 insertions, 855 deletions
diff --git a/apps/workflowengine/lib/AppInfo/Application.php b/apps/workflowengine/lib/AppInfo/Application.php index 28e32092419..93b0ca49260 100644 --- a/apps/workflowengine/lib/AppInfo/Application.php +++ b/apps/workflowengine/lib/AppInfo/Application.php @@ -1,78 +1,105 @@ <?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/>. - * + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ - namespace OCA\WorkflowEngine\AppInfo; -class Application extends \OCP\AppFramework\App { +use Closure; +use OCA\WorkflowEngine\Helper\LogContext; +use OCA\WorkflowEngine\Listener\LoadAdditionalSettingsScriptsListener; +use OCA\WorkflowEngine\Manager; +use OCA\WorkflowEngine\Service\Logger; +use OCP\AppFramework\App; +use OCP\AppFramework\Bootstrap\IBootContext; +use OCP\AppFramework\Bootstrap\IBootstrap; +use OCP\AppFramework\Bootstrap\IRegistrationContext; +use OCP\EventDispatcher\Event; +use OCP\EventDispatcher\IEventDispatcher; +use OCP\WorkflowEngine\Events\LoadSettingsScriptsEvent; +use OCP\WorkflowEngine\IEntity; +use OCP\WorkflowEngine\IOperation; +use Psr\Container\ContainerExceptionInterface; +use Psr\Container\ContainerInterface; +use Psr\Log\LoggerInterface; + +class Application extends App implements IBootstrap { + public const APP_ID = 'workflowengine'; public function __construct() { - parent::__construct('workflowengine'); + parent::__construct(self::APP_ID); + } + + public function register(IRegistrationContext $context): void { + $context->registerEventListener( + LoadSettingsScriptsEvent::class, + LoadAdditionalSettingsScriptsListener::class, + -100 + ); + } - $this->getContainer()->registerAlias('FlowOperationsController', 'OCA\WorkflowEngine\Controller\FlowOperations'); - $this->getContainer()->registerAlias('RequestTimeController', 'OCA\WorkflowEngine\Controller\RequestTime'); + public function boot(IBootContext $context): void { + $context->injectFn(Closure::fromCallable([$this, 'registerRuleListeners'])); } - /** - * Register all hooks and listeners - */ - public function registerHooksAndListeners() { - $dispatcher = $this->getContainer()->getServer()->getEventDispatcher(); - $dispatcher->addListener( - 'OCP\WorkflowEngine::loadAdditionalSettingScripts', - function() { - if (!function_exists('style')) { - // This is hacky, but we need to load the template class - class_exists('OCP\Template', true); - } + private function registerRuleListeners(IEventDispatcher $dispatcher, + ContainerInterface $container, + LoggerInterface $logger): void { + /** @var Manager $manager */ + $manager = $container->get(Manager::class); + $configuredEvents = $manager->getAllConfiguredEvents(); - style('workflowengine', [ - 'admin', - ]); + foreach ($configuredEvents as $operationClass => $events) { + foreach ($events as $entityClass => $eventNames) { + array_map(function (string $eventName) use ($manager, $container, $dispatcher, $logger, $operationClass, $entityClass): void { + $dispatcher->addListener( + $eventName, + function ($event) use ($manager, $container, $eventName, $logger, $operationClass, $entityClass): void { + $ruleMatcher = $manager->getRuleMatcher(); + try { + /** @var IEntity $entity */ + $entity = $container->get($entityClass); + /** @var IOperation $operation */ + $operation = $container->get($operationClass); - script('core', [ - 'files/fileinfo', - 'files/client', - 'oc-backbone-webdav', - 'systemtags/systemtags', - 'systemtags/systemtagmodel', - 'systemtags/systemtagscollection', - ]); + $ruleMatcher->setEventName($eventName); + $ruleMatcher->setEntity($entity); + $ruleMatcher->setOperation($operation); - vendor_script('jsTimezoneDetect/jstz'); + $ctx = new LogContext(); + $ctx + ->setOperation($operation) + ->setEntity($entity) + ->setEventName($eventName); - script('workflowengine', [ - 'admin', + /** @var Logger $flowLogger */ + $flowLogger = $container->get(Logger::class); + $flowLogger->logEventInit($ctx); - // Check plugins - 'filemimetypeplugin', - 'filesizeplugin', - 'filesystemtagsplugin', - 'requestremoteaddressplugin', - 'requesttimeplugin', - 'requesturlplugin', - 'requestuseragentplugin', - 'usergroupmembershipplugin', - ]); - }, - -100 - ); + if ($event instanceof Event) { + $entity->prepareRuleMatcher($ruleMatcher, $eventName, $event); + $operation->onEvent($eventName, $event, $ruleMatcher); + } else { + $logger->debug( + 'Cannot handle event {name} of {event} against entity {entity} and operation {operation}', + [ + 'app' => self::APP_ID, + 'name' => $eventName, + 'event' => get_class($event), + 'entity' => $entityClass, + 'operation' => $operationClass, + ] + ); + } + $flowLogger->logEventDone($ctx); + } catch (ContainerExceptionInterface $e) { + // Ignore query exceptions since they might occur when an entity/operation were set up before by an app that is disabled now + } + } + ); + }, $eventNames ?? []); + } + } } } diff --git a/apps/workflowengine/lib/BackgroundJobs/Rotate.php b/apps/workflowengine/lib/BackgroundJobs/Rotate.php new file mode 100644 index 00000000000..d7984b1226a --- /dev/null +++ b/apps/workflowengine/lib/BackgroundJobs/Rotate.php @@ -0,0 +1,40 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\WorkflowEngine\BackgroundJobs; + +use OCA\WorkflowEngine\AppInfo\Application; +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\BackgroundJob\TimedJob; +use OCP\IConfig; +use OCP\Log\RotationTrait; +use OCP\Server; + +class Rotate extends TimedJob { + use RotationTrait; + + public function __construct(ITimeFactory $time) { + parent::__construct($time); + $this->setInterval(60 * 60 * 3); + } + + protected function run($argument) { + $config = Server::get(IConfig::class); + $default = $config->getSystemValue('datadirectory', \OC::$SERVERROOT . '/data') . '/flow.log'; + $this->filePath = trim((string)$config->getAppValue(Application::APP_ID, 'logfile', $default)); + + if ($this->filePath === '') { + // disabled, nothing to do + return; + } + + $this->maxSize = $config->getSystemValue('log_rotate_size', 100 * 1024 * 1024); + + if ($this->shouldRotateBySize()) { + $this->rotate(); + } + } +} diff --git a/apps/workflowengine/lib/Check/AbstractStringCheck.php b/apps/workflowengine/lib/Check/AbstractStringCheck.php index 0fd728e3496..d92e9901365 100644 --- a/apps/workflowengine/lib/Check/AbstractStringCheck.php +++ b/apps/workflowengine/lib/Check/AbstractStringCheck.php @@ -1,52 +1,26 @@ <?php + /** - * @copyright Copyright (c) 2016 Joas Schilling <coding@schilljs.com> - * - * @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/>. - * + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ - namespace OCA\WorkflowEngine\Check; - -use OCP\Files\Storage\IStorage; use OCP\IL10N; use OCP\WorkflowEngine\ICheck; +use OCP\WorkflowEngine\IManager; abstract class AbstractStringCheck implements ICheck { /** @var array[] Nested array: [Pattern => [ActualValue => Regex Result]] */ protected $matches; - /** @var IL10N */ - protected $l; - /** * @param IL10N $l */ - public function __construct(IL10N $l) { - $this->l = $l; - } - - /** - * @param IStorage $storage - * @param string $path - */ - public function setFileInfo(IStorage $storage, $path) { - // Nothing changes here with a different path + public function __construct( + protected IL10N $l, + ) { } /** @@ -59,7 +33,7 @@ abstract class AbstractStringCheck implements ICheck { * @param string $value * @return bool */ - public function executeCheck($operator, $value) { + public function executeCheck($operator, $value) { $actualValue = $this->getActualValue(); return $this->executeStringCheck($operator, $value, $actualValue); } @@ -73,7 +47,7 @@ abstract class AbstractStringCheck implements ICheck { protected function executeStringCheck($operator, $checkValue, $actualValue) { if ($operator === 'is') { return $checkValue === $actualValue; - } else if ($operator === '!is') { + } elseif ($operator === '!is') { return $checkValue !== $actualValue; } else { $match = $this->match($checkValue, $actualValue); @@ -95,12 +69,22 @@ abstract class AbstractStringCheck implements ICheck { throw new \UnexpectedValueException($this->l->t('The given operator is invalid'), 1); } - if (in_array($operator, ['matches', '!matches']) && - @preg_match($value, null) === false) { + if (in_array($operator, ['matches', '!matches']) + && @preg_match($value, null) === false) { throw new \UnexpectedValueException($this->l->t('The given regular expression is invalid'), 2); } } + public function supportedEntities(): array { + // universal by default + return []; + } + + public function isAvailableForScope(int $scope): bool { + // admin only by default + return $scope === IManager::SCOPE_ADMIN; + } + /** * @param string $pattern * @param string $subject diff --git a/apps/workflowengine/lib/Check/FileMimeType.php b/apps/workflowengine/lib/Check/FileMimeType.php index fe4a83bb906..a8dfa64528e 100644 --- a/apps/workflowengine/lib/Check/FileMimeType.php +++ b/apps/workflowengine/lib/Check/FileMimeType.php @@ -1,166 +1,124 @@ <?php + /** - * @copyright Copyright (c) 2016 Joas Schilling <coding@schilljs.com> - * - * @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/>. - * + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ - namespace OCA\WorkflowEngine\Check; - +use OC\Files\Storage\Local; +use OCA\WorkflowEngine\Entity\File; use OCP\Files\IMimeTypeDetector; use OCP\Files\Storage\IStorage; use OCP\IL10N; use OCP\IRequest; +use OCP\WorkflowEngine\IFileCheck; -class FileMimeType extends AbstractStringCheck { +class FileMimeType extends AbstractStringCheck implements IFileCheck { + use TFileCheck { + setFileInfo as _setFileInfo; + } /** @var array */ protected $mimeType; - /** @var IRequest */ - protected $request; - - /** @var IMimeTypeDetector */ - protected $mimeTypeDetector; - - /** @var IStorage */ - protected $storage; - - /** @var string */ - protected $path; - /** * @param IL10N $l * @param IRequest $request * @param IMimeTypeDetector $mimeTypeDetector */ - public function __construct(IL10N $l, IRequest $request, IMimeTypeDetector $mimeTypeDetector) { + public function __construct( + IL10N $l, + protected IRequest $request, + protected IMimeTypeDetector $mimeTypeDetector, + ) { parent::__construct($l); - $this->request = $request; - $this->mimeTypeDetector = $mimeTypeDetector; } /** * @param IStorage $storage * @param string $path + * @param bool $isDir */ - public function setFileInfo(IStorage $storage, $path) { - $this->storage = $storage; - $this->path = $path; + public function setFileInfo(IStorage $storage, string $path, bool $isDir = false): void { + $this->_setFileInfo($storage, $path, $isDir); if (!isset($this->mimeType[$this->storage->getId()][$this->path]) || $this->mimeType[$this->storage->getId()][$this->path] === '') { - $this->mimeType[$this->storage->getId()][$this->path] = null; + if ($isDir) { + $this->mimeType[$this->storage->getId()][$this->path] = 'httpd/unix-directory'; + } else { + $this->mimeType[$this->storage->getId()][$this->path] = null; + } } } /** + * The mimetype is only cached if the file has a valid mimetype. Otherwise files access + * control will cache "application/octet-stream" for all the target node on: + * rename, move, copy and all other methods which create a new item + * + * To check this: + * 1. Add an automated tagging rule which tags on mimetype NOT "httpd/unix-directory" + * 2. Add an access control rule which checks for any mimetype + * 3. Create a folder and rename it, the folder should not be tagged, but it is + * + * @param string $storageId + * @param string|null $path + * @param string $mimeType * @return string */ - protected function getActualValue() { - if ($this->mimeType[$this->storage->getId()][$this->path] !== null) { - return $this->mimeType[$this->storage->getId()][$this->path]; - } - - if ($this->isWebDAVRequest()) { - // Creating a folder - if ($this->request->getMethod() === 'MKCOL') { - $this->mimeType[$this->storage->getId()][$this->path] = 'httpd/unix-directory'; - return $this->mimeType[$this->storage->getId()][$this->path]; - } - - if ($this->request->getMethod() === 'PUT') { - $path = $this->request->getPathInfo(); - $this->mimeType[$this->storage->getId()][$this->path] = $this->mimeTypeDetector->detectPath($path); - return $this->mimeType[$this->storage->getId()][$this->path]; - } - } else if ($this->isPublicWebDAVRequest()) { - if ($this->request->getMethod() === 'PUT') { - $path = $this->request->getPathInfo(); - if (strpos($path, '/webdav/') === 0) { - $path = substr($path, strlen('/webdav')); - } - $path = $this->path . $path; - $this->mimeType[$this->storage->getId()][$path] = $this->mimeTypeDetector->detectPath($path); - return $this->mimeType[$this->storage->getId()][$path]; - } + protected function cacheAndReturnMimeType(string $storageId, ?string $path, string $mimeType): string { + if ($path !== null && $mimeType !== 'application/octet-stream') { + $this->mimeType[$storageId][$path] = $mimeType; } - if (in_array($this->request->getMethod(), ['POST', 'PUT'])) { - $files = $this->request->getUploadedFile('files'); - if (isset($files['type'][0])) { - $mimeType = $files['type'][0]; - if ($this->mimeType === 'application/octet-stream') { - // Maybe not... - $mimeTypeTest = $this->mimeTypeDetector->detectPath($files['name'][0]); - if ($mimeTypeTest !== 'application/octet-stream' && $mimeTypeTest !== false) { - $mimeType = $mimeTypeTest; - } else { - $mimeTypeTest = $this->mimeTypeDetector->detect($files['tmp_name'][0]); - if ($mimeTypeTest !== 'application/octet-stream' && $mimeTypeTest !== false) { - $mimeType = $mimeTypeTest; - } - } - } - $this->mimeType[$this->storage->getId()][$this->path] = $mimeType; - return $mimeType; - } - } - - $this->mimeType[$this->storage->getId()][$this->path] = $this->storage->getMimeType($this->path); - if ($this->mimeType[$this->storage->getId()][$this->path] === 'application/octet-stream') { - $this->mimeType[$this->storage->getId()][$this->path] = $this->detectMimetypeFromPath(); - } + return $mimeType; + } - return $this->mimeType[$this->storage->getId()][$this->path]; + /** + * Make sure that even though the content based check returns an application/octet-stream can still be checked based on mimetypemappings of their extension + * + * @param string $operator + * @param string $value + * @return bool + */ + public function executeCheck($operator, $value) { + return $this->executeStringCheck($operator, $value, $this->getActualValue()); } /** * @return string */ - protected function detectMimetypeFromPath() { - $mimeType = $this->mimeTypeDetector->detectPath($this->path); - if ($mimeType !== 'application/octet-stream' && $mimeType !== false) { - return $mimeType; + protected function getActualValue() { + if ($this->mimeType[$this->storage->getId()][$this->path] !== null) { + return $this->mimeType[$this->storage->getId()][$this->path]; + } + $cacheEntry = $this->storage->getCache()->get($this->path); + if ($cacheEntry && $cacheEntry->getMimeType() !== 'application/octet-stream') { + return $this->cacheAndReturnMimeType($this->storage->getId(), $this->path, $cacheEntry->getMimeType()); } - if ($this->storage->instanceOfStorage('\OC\Files\Storage\Local') - || $this->storage->instanceOfStorage('\OC\Files\Storage\Home') - || $this->storage->instanceOfStorage('\OC\Files\ObjectStore\HomeObjectStoreStorage')) { - $localFile = $this->storage->getLocalFile($this->path); - if ($localFile !== false) { - $mimeType = $this->mimeTypeDetector->detect($localFile); - if ($mimeType !== false) { - return $mimeType; - } - } + if ($this->storage->file_exists($this->path) + && $this->storage->filesize($this->path) + && $this->storage->instanceOfStorage(Local::class) + ) { + $path = $this->storage->getLocalFile($this->path); + $mimeType = $this->mimeTypeDetector->detectContent($path); + return $this->cacheAndReturnMimeType($this->storage->getId(), $this->path, $mimeType); + } - return 'application/octet-stream'; - } else { - $handle = $this->storage->fopen($this->path, 'r'); - $data = fread($handle, 8024); - fclose($handle); - $mimeType = $this->mimeTypeDetector->detectString($data); - if ($mimeType !== false) { - return $mimeType; + if ($this->isWebDAVRequest() || $this->isPublicWebDAVRequest()) { + // Creating a folder + if ($this->request->getMethod() === 'MKCOL') { + return 'httpd/unix-directory'; } - - return 'application/octet-stream'; } + + // We do not cache this, as the file did not exist yet. + // In case it does in the future, we will check with detectContent() + // again to get the real mimetype of the content, rather than + // guessing it from the path. + return $this->mimeTypeDetector->detectPath($this->path); } /** @@ -168,10 +126,12 @@ class FileMimeType extends AbstractStringCheck { */ protected function isWebDAVRequest() { return substr($this->request->getScriptName(), 0 - strlen('/remote.php')) === '/remote.php' && ( - $this->request->getPathInfo() === '/webdav' || - strpos($this->request->getPathInfo(), '/webdav/') === 0 || - $this->request->getPathInfo() === '/dav/files' || - strpos($this->request->getPathInfo(), '/dav/files/') === 0 + $this->request->getPathInfo() === '/webdav' + || str_starts_with($this->request->getPathInfo() ?? '', '/webdav/') + || $this->request->getPathInfo() === '/dav/files' + || str_starts_with($this->request->getPathInfo() ?? '', '/dav/files/') + || $this->request->getPathInfo() === '/dav/uploads' + || str_starts_with($this->request->getPathInfo() ?? '', '/dav/uploads/') ); } @@ -180,8 +140,16 @@ class FileMimeType extends AbstractStringCheck { */ protected function isPublicWebDAVRequest() { return substr($this->request->getScriptName(), 0 - strlen('/public.php')) === '/public.php' && ( - $this->request->getPathInfo() === '/webdav' || - strpos($this->request->getPathInfo(), '/webdav/') === 0 + $this->request->getPathInfo() === '/webdav' + || str_starts_with($this->request->getPathInfo() ?? '', '/webdav/') ); } + + public function supportedEntities(): array { + return [ File::class ]; + } + + public function isAvailableForScope(int $scope): bool { + return true; + } } diff --git a/apps/workflowengine/lib/Check/FileName.php b/apps/workflowengine/lib/Check/FileName.php new file mode 100644 index 00000000000..4a9d503018f --- /dev/null +++ b/apps/workflowengine/lib/Check/FileName.php @@ -0,0 +1,75 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\WorkflowEngine\Check; + +use OC\Files\Storage\Local; +use OCA\WorkflowEngine\Entity\File; +use OCP\Files\Mount\IMountManager; +use OCP\IL10N; +use OCP\IRequest; +use OCP\WorkflowEngine\IFileCheck; + +class FileName extends AbstractStringCheck implements IFileCheck { + use TFileCheck; + + /** + * @param IL10N $l + * @param IRequest $request + */ + public function __construct( + IL10N $l, + protected IRequest $request, + private IMountManager $mountManager, + ) { + parent::__construct($l); + } + + /** + * @return string + */ + protected function getActualValue(): string { + $fileName = $this->path === null ? '' : basename($this->path); + if ($fileName === '' && (!$this->storage->isLocal() || $this->storage->instanceOfStorage(Local::class))) { + // Return the mountpoint name of external storage that are not mounted as user home + $mountPoints = $this->mountManager->findByStorageId($this->storage->getId()); + if (empty($mountPoints) || $mountPoints[0]->getMountType() !== 'external') { + return $fileName; + } + $mountPointPath = rtrim($mountPoints[0]->getMountPoint(), '/'); + $mountPointPieces = explode('/', $mountPointPath); + $mountPointName = array_pop($mountPointPieces); + if (!empty($mountPointName) && $mountPointName !== 'files' && count($mountPointPieces) !== 2) { + return $mountPointName; + } + } + return $fileName; + } + + /** + * @param string $operator + * @param string $checkValue + * @param string $actualValue + * @return bool + */ + protected function executeStringCheck($operator, $checkValue, $actualValue): bool { + if ($operator === 'is' || $operator === '!is') { + $checkValue = mb_strtolower($checkValue); + $actualValue = mb_strtolower($actualValue); + } + return parent::executeStringCheck($operator, $checkValue, $actualValue); + } + + public function supportedEntities(): array { + return [ File::class ]; + } + + public function isAvailableForScope(int $scope): bool { + return true; + } +} diff --git a/apps/workflowengine/lib/Check/FileSize.php b/apps/workflowengine/lib/Check/FileSize.php index 7e48f0f6038..5ee03ccc9cf 100644 --- a/apps/workflowengine/lib/Check/FileSize.php +++ b/apps/workflowengine/lib/Check/FileSize.php @@ -1,28 +1,12 @@ <?php + /** - * @copyright Copyright (c) 2016 Joas Schilling <coding@schilljs.com> - * - * @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/>. - * + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ - namespace OCA\WorkflowEngine\Check; - -use OCP\Files\Storage\IStorage; +use OCA\WorkflowEngine\Entity\File; use OCP\IL10N; use OCP\IRequest; use OCP\Util; @@ -33,26 +17,14 @@ class FileSize implements ICheck { /** @var int */ protected $size; - /** @var IL10N */ - protected $l; - - /** @var IRequest */ - protected $request; - /** * @param IL10N $l * @param IRequest $request */ - public function __construct(IL10N $l, IRequest $request) { - $this->l = $l; - $this->request = $request; - } - - /** - * @param IStorage $storage - * @param string $path - */ - public function setFileInfo(IStorage $storage, $path) { + public function __construct( + protected IL10N $l, + protected IRequest $request, + ) { } /** @@ -116,4 +88,12 @@ class FileSize implements ICheck { $this->size = $size; return $this->size; } + + public function supportedEntities(): array { + return [ File::class ]; + } + + public function isAvailableForScope(int $scope): bool { + return true; + } } diff --git a/apps/workflowengine/lib/Check/FileSystemTags.php b/apps/workflowengine/lib/Check/FileSystemTags.php index 4a2b87fd53e..811571f558a 100644 --- a/apps/workflowengine/lib/Check/FileSystemTags.php +++ b/apps/workflowengine/lib/Check/FileSystemTags.php @@ -1,37 +1,28 @@ <?php + /** - * @copyright Copyright (c) 2016 Joas Schilling <coding@schilljs.com> - * - * @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/>. - * + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ - namespace OCA\WorkflowEngine\Check; - +use OC\Files\Storage\Wrapper\Jail; +use OCA\Files_Sharing\SharedStorage; +use OCA\WorkflowEngine\Entity\File; use OCP\Files\Cache\ICache; use OCP\Files\IHomeStorage; -use OCP\Files\Storage\IStorage; +use OCP\IGroupManager; use OCP\IL10N; +use OCP\IUser; +use OCP\IUserSession; use OCP\SystemTag\ISystemTagManager; use OCP\SystemTag\ISystemTagObjectMapper; use OCP\SystemTag\TagNotFoundException; use OCP\WorkflowEngine\ICheck; +use OCP\WorkflowEngine\IFileCheck; -class FileSystemTags implements ICheck { +class FileSystemTags implements ICheck, IFileCheck { + use TFileCheck; /** @var array */ protected $fileIds; @@ -39,39 +30,13 @@ class FileSystemTags implements ICheck { /** @var array */ protected $fileSystemTags; - /** @var IL10N */ - protected $l; - - /** @var ISystemTagManager */ - protected $systemTagManager; - - /** @var ISystemTagObjectMapper */ - protected $systemTagObjectMapper; - - /** @var IStorage */ - protected $storage; - - /** @var string */ - protected $path; - - /** - * @param IL10N $l - * @param ISystemTagManager $systemTagManager - * @param ISystemTagObjectMapper $systemTagObjectMapper - */ - public function __construct(IL10N $l, ISystemTagManager $systemTagManager, ISystemTagObjectMapper $systemTagObjectMapper) { - $this->l = $l; - $this->systemTagManager = $systemTagManager; - $this->systemTagObjectMapper = $systemTagObjectMapper; - } - - /** - * @param IStorage $storage - * @param string $path - */ - public function setFileInfo(IStorage $storage, $path) { - $this->storage = $storage; - $this->path = $path; + public function __construct( + protected IL10N $l, + protected ISystemTagManager $systemTagManager, + protected ISystemTagObjectMapper $systemTagObjectMapper, + protected IUserSession $userSession, + protected IGroupManager $groupManager, + ) { } /** @@ -95,7 +60,18 @@ class FileSystemTags implements ICheck { } try { - $this->systemTagManager->getTagsByIds($value); + $tags = $this->systemTagManager->getTagsByIds($value); + + $user = $this->userSession->getUser(); + $isAdmin = $user instanceof IUser && $this->groupManager->isAdmin($user->getUID()); + + if (!$isAdmin) { + foreach ($tags as $tag) { + if (!$tag->isUserVisible()) { + throw new \UnexpectedValueException($this->l->t('The given tag id is invalid'), 4); + } + } + } } catch (TagNotFoundException $e) { throw new \UnexpectedValueException($this->l->t('The given tag id is invalid'), 2); } catch (\InvalidArgumentException $e) { @@ -109,7 +85,7 @@ class FileSystemTags implements ICheck { */ protected function getSystemTags() { $cache = $this->storage->getCache(); - $fileIds = $this->getFileIds($cache, $this->path, !$this->storage->instanceOfStorage(IHomeStorage::class)); + $fileIds = $this->getFileIds($cache, $this->path, !$this->storage->instanceOfStorage(IHomeStorage::class) || $this->storage->instanceOfStorage(SharedStorage::class)); $systemTags = []; foreach ($fileIds as $i => $fileId) { @@ -141,23 +117,29 @@ class FileSystemTags implements ICheck { */ protected function getFileIds(ICache $cache, $path, $isExternalStorage) { $cacheId = $cache->getNumericStorageId(); - if (isset($this->fileIds[$cacheId][$path])) { - return $this->fileIds[$cacheId][$path]; + if ($this->storage->instanceOfStorage(Jail::class)) { + $absolutePath = $this->storage->getUnjailedPath($path); + } else { + $absolutePath = $path; + } + + if (isset($this->fileIds[$cacheId][$absolutePath])) { + return $this->fileIds[$cacheId][$absolutePath]; } $parentIds = []; if ($path !== $this->dirname($path)) { $parentIds = $this->getFileIds($cache, $this->dirname($path), $isExternalStorage); - } else if (!$isExternalStorage) { + } elseif (!$isExternalStorage) { return []; } $fileId = $cache->getId($path); if ($fileId !== -1) { - $parentIds[] = $cache->getId($path); + $parentIds[] = $fileId; } - $this->fileIds[$cacheId][$path] = $parentIds; + $this->fileIds[$cacheId][$absolutePath] = $parentIds; return $parentIds; } @@ -166,4 +148,12 @@ class FileSystemTags implements ICheck { $dir = dirname($path); return $dir === '.' ? '' : $dir; } + + public function supportedEntities(): array { + return [ File::class ]; + } + + public function isAvailableForScope(int $scope): bool { + return true; + } } diff --git a/apps/workflowengine/lib/Check/RequestRemoteAddress.php b/apps/workflowengine/lib/Check/RequestRemoteAddress.php index de9738fb631..b6f8fef5aed 100644 --- a/apps/workflowengine/lib/Check/RequestRemoteAddress.php +++ b/apps/workflowengine/lib/Check/RequestRemoteAddress.php @@ -1,55 +1,25 @@ <?php + /** - * @copyright Copyright (c) 2016 Joas Schilling <coding@schilljs.com> - * - * @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/>. - * + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ - namespace OCA\WorkflowEngine\Check; - -use OCP\Files\Storage\IStorage; use OCP\IL10N; use OCP\IRequest; use OCP\WorkflowEngine\ICheck; class RequestRemoteAddress implements ICheck { - /** @var IL10N */ - protected $l; - - /** @var IRequest */ - protected $request; - /** * @param IL10N $l * @param IRequest $request */ - public function __construct(IL10N $l, IRequest $request) { - $this->l = $l; - $this->request = $request; - } - - /** - * @param IStorage $storage - * @param string $path - */ - public function setFileInfo(IStorage $storage, $path) { - // A different path doesn't change time, so nothing to do here. + public function __construct( + protected IL10N $l, + protected IRequest $request, + ) { } /** @@ -63,9 +33,9 @@ class RequestRemoteAddress implements ICheck { if ($operator === 'matchesIPv4') { return $this->matchIPv4($actualValue, $decodedValue[0], $decodedValue[1]); - } else if ($operator === '!matchesIPv4') { + } elseif ($operator === '!matchesIPv4') { return !$this->matchIPv4($actualValue, $decodedValue[0], $decodedValue[1]); - } else if ($operator === 'matchesIPv6') { + } elseif ($operator === 'matchesIPv6') { return $this->matchIPv6($actualValue, $decodedValue[0], $decodedValue[1]); } else { return !$this->matchIPv6($actualValue, $decodedValue[0], $decodedValue[1]); @@ -83,7 +53,7 @@ class RequestRemoteAddress implements ICheck { } $decodedValue = explode('/', $value); - if (sizeof($decodedValue) !== 2) { + if (count($decodedValue) !== 2) { throw new \UnexpectedValueException($this->l->t('The given IP range is invalid'), 2); } @@ -105,7 +75,7 @@ class RequestRemoteAddress implements ICheck { } /** - * Based on http://stackoverflow.com/a/594134 + * Based on https://stackoverflow.com/a/594134 * @param string $ip * @param string $rangeIp * @param int $bits @@ -119,7 +89,7 @@ class RequestRemoteAddress implements ICheck { } /** - * Based on http://stackoverflow.com/a/7951507 + * Based on https://stackoverflow.com/a/7951507 * @param string $ip * @param string $rangeIp * @param int $bits @@ -138,7 +108,7 @@ class RequestRemoteAddress implements ICheck { } /** - * Based on http://stackoverflow.com/a/7951507 + * Based on https://stackoverflow.com/a/7951507 * @param string $packedIp * @return string */ @@ -151,4 +121,32 @@ class RequestRemoteAddress implements ICheck { } return str_pad($binaryIp, 128, '0', STR_PAD_RIGHT); } + + /** + * returns a list of Entities the checker supports. The values must match + * the class name of the entity. + * + * An empty result means the check is universally available. + * + * @since 18.0.0 + */ + public function supportedEntities(): array { + return []; + } + + /** + * returns whether the operation can be used in the requested scope. + * + * Scope IDs are defined as constants in OCP\WorkflowEngine\IManager. At + * time of writing these are SCOPE_ADMIN and SCOPE_USER. + * + * For possibly unknown future scopes the recommended behaviour is: if + * user scope is permitted, the default behaviour should return `true`, + * otherwise `false`. + * + * @since 18.0.0 + */ + public function isAvailableForScope(int $scope): bool { + return true; + } } diff --git a/apps/workflowengine/lib/Check/RequestTime.php b/apps/workflowengine/lib/Check/RequestTime.php index 2aa79e77673..a49986652b8 100644 --- a/apps/workflowengine/lib/Check/RequestTime.php +++ b/apps/workflowengine/lib/Check/RequestTime.php @@ -1,60 +1,29 @@ <?php + /** - * @copyright Copyright (c) 2016 Joas Schilling <coding@schilljs.com> - * - * @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/>. - * + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ - namespace OCA\WorkflowEngine\Check; - use OCP\AppFramework\Utility\ITimeFactory; -use OCP\Files\Storage\IStorage; use OCP\IL10N; use OCP\WorkflowEngine\ICheck; class RequestTime implements ICheck { - - const REGEX_TIME = '([0-1][0-9]|2[0-3]):([0-5][0-9])'; - const REGEX_TIMEZONE = '([a-zA-Z]+(?:\\/[a-zA-Z\-\_]+)+)'; + public const REGEX_TIME = '([0-1][0-9]|2[0-3]):([0-5][0-9])'; + public const REGEX_TIMEZONE = '([a-zA-Z]+(?:\\/[a-zA-Z\-\_]+)+)'; /** @var bool[] */ protected $cachedResults; - /** @var IL10N */ - protected $l; - - /** @var ITimeFactory */ - protected $timeFactory; - /** * @param ITimeFactory $timeFactory */ - public function __construct(IL10N $l, ITimeFactory $timeFactory) { - $this->l = $l; - $this->timeFactory = $timeFactory; - } - - /** - * @param IStorage $storage - * @param string $path - */ - public function setFileInfo(IStorage $storage, $path) { - // A different path doesn't change time, so nothing to do here. + public function __construct( + protected IL10N $l, + protected ITimeFactory $timeFactory, + ) { } /** @@ -90,11 +59,11 @@ class RequestTime implements ICheck { * @return int */ protected function getTimestamp($currentTimestamp, $value) { - list($time1, $timezone1) = explode(' ', $value); - list($hour1, $minute1) = explode(':', $time1); + [$time1, $timezone1] = explode(' ', $value); + [$hour1, $minute1] = explode(':', $time1); $date1 = new \DateTime('now', new \DateTimeZone($timezone1)); $date1->setTimestamp($currentTimestamp); - $date1->setTime($hour1, $minute1); + $date1->setTime((int)$hour1, (int)$minute1); return $date1->getTimestamp(); } @@ -116,14 +85,30 @@ class RequestTime implements ICheck { } $values = json_decode($value, true); - $time1 = \DateTime::createFromFormat('H:i e', $values[0]); + $time1 = \DateTime::createFromFormat('H:i e', (string)$values[0]); if ($time1 === false) { throw new \UnexpectedValueException($this->l->t('The given start time is invalid'), 3); } - $time2 = \DateTime::createFromFormat('H:i e', $values[1]); + $time2 = \DateTime::createFromFormat('H:i e', (string)$values[1]); if ($time2 === false) { throw new \UnexpectedValueException($this->l->t('The given end time is invalid'), 4); } } + + public function isAvailableForScope(int $scope): bool { + return true; + } + + /** + * returns a list of Entities the checker supports. The values must match + * the class name of the entity. + * + * An empty result means the check is universally available. + * + * @since 18.0.0 + */ + public function supportedEntities(): array { + return []; + } } diff --git a/apps/workflowengine/lib/Check/RequestURL.php b/apps/workflowengine/lib/Check/RequestURL.php index 36d41c101f2..fb2ac7e8fd5 100644 --- a/apps/workflowengine/lib/Check/RequestURL.php +++ b/apps/workflowengine/lib/Check/RequestURL.php @@ -1,45 +1,29 @@ <?php + /** - * @copyright Copyright (c) 2016 Joas Schilling <coding@schilljs.com> - * - * @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/>. - * + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ - namespace OCA\WorkflowEngine\Check; - use OCP\IL10N; use OCP\IRequest; class RequestURL extends AbstractStringCheck { + public const CLI = 'cli'; - /** @var string */ + /** @var ?string */ protected $url; - /** @var IRequest */ - protected $request; - /** * @param IL10N $l * @param IRequest $request */ - public function __construct(IL10N $l, IRequest $request) { + public function __construct( + IL10N $l, + protected IRequest $request, + ) { parent::__construct($l); - $this->request = $request; } /** @@ -47,8 +31,12 @@ class RequestURL extends AbstractStringCheck { * @param string $value * @return bool */ - public function executeCheck($operator, $value) { - $actualValue = $this->getActualValue(); + public function executeCheck($operator, $value) { + if (\OC::$CLI) { + $actualValue = $this->url = RequestURL::CLI; + } else { + $actualValue = $this->getActualValue(); + } if (in_array($operator, ['is', '!is'])) { switch ($value) { case 'webdav': @@ -78,15 +66,15 @@ class RequestURL extends AbstractStringCheck { return $this->url; // E.g. https://localhost/nextcloud/index.php/apps/files_texteditor/ajax/loadfile } - /** - * @return bool - */ - protected function isWebDAVRequest() { + protected function isWebDAVRequest(): bool { + if ($this->url === RequestURL::CLI) { + return false; + } return substr($this->request->getScriptName(), 0 - strlen('/remote.php')) === '/remote.php' && ( - $this->request->getPathInfo() === '/webdav' || - strpos($this->request->getPathInfo(), '/webdav/') === 0 || - $this->request->getPathInfo() === '/dav/files' || - strpos($this->request->getPathInfo(), '/dav/files/') === 0 + $this->request->getPathInfo() === '/webdav' + || str_starts_with($this->request->getPathInfo() ?? '', '/webdav/') + || $this->request->getPathInfo() === '/dav/files' + || str_starts_with($this->request->getPathInfo() ?? '', '/dav/files/') ); } } diff --git a/apps/workflowengine/lib/Check/RequestUserAgent.php b/apps/workflowengine/lib/Check/RequestUserAgent.php index 7a8d4a71acf..572ef567074 100644 --- a/apps/workflowengine/lib/Check/RequestUserAgent.php +++ b/apps/workflowengine/lib/Check/RequestUserAgent.php @@ -1,42 +1,25 @@ <?php + /** - * @copyright Copyright (c) 2016 Joas Schilling <coding@schilljs.com> - * - * @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/>. - * + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ - namespace OCA\WorkflowEngine\Check; - use OCP\IL10N; use OCP\IRequest; class RequestUserAgent extends AbstractStringCheck { - /** @var IRequest */ - protected $request; - /** * @param IL10N $l * @param IRequest $request */ - public function __construct(IL10N $l, IRequest $request) { + public function __construct( + IL10N $l, + protected IRequest $request, + ) { parent::__construct($l); - $this->request = $request; } /** @@ -44,9 +27,9 @@ class RequestUserAgent extends AbstractStringCheck { * @param string $value * @return bool */ - public function executeCheck($operator, $value) { + public function executeCheck($operator, $value) { $actualValue = $this->getActualValue(); - if (in_array($operator, ['is', '!is'])) { + if (in_array($operator, ['is', '!is'], true)) { switch ($value) { case 'android': $operator = $operator === 'is' ? 'matches' : '!matches'; @@ -60,6 +43,14 @@ class RequestUserAgent extends AbstractStringCheck { $operator = $operator === 'is' ? 'matches' : '!matches'; $value = IRequest::USER_AGENT_CLIENT_DESKTOP; break; + case 'mail': + if ($operator === 'is') { + return $this->executeStringCheck('matches', IRequest::USER_AGENT_OUTLOOK_ADDON, $actualValue) + || $this->executeStringCheck('matches', IRequest::USER_AGENT_THUNDERBIRD_ADDON, $actualValue); + } + + return $this->executeStringCheck('!matches', IRequest::USER_AGENT_OUTLOOK_ADDON, $actualValue) + && $this->executeStringCheck('!matches', IRequest::USER_AGENT_THUNDERBIRD_ADDON, $actualValue); } } return $this->executeStringCheck($operator, $value, $actualValue); @@ -69,6 +60,10 @@ class RequestUserAgent extends AbstractStringCheck { * @return string */ protected function getActualValue() { - return (string) $this->request->getHeader('User-Agent'); + return $this->request->getHeader('User-Agent'); + } + + public function isAvailableForScope(int $scope): bool { + return true; } } diff --git a/apps/workflowengine/lib/Check/TFileCheck.php b/apps/workflowengine/lib/Check/TFileCheck.php new file mode 100644 index 00000000000..a514352e047 --- /dev/null +++ b/apps/workflowengine/lib/Check/TFileCheck.php @@ -0,0 +1,55 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\WorkflowEngine\Check; + +use OCA\WorkflowEngine\AppInfo\Application; +use OCA\WorkflowEngine\Entity\File; +use OCP\Files\Node; +use OCP\Files\NotFoundException; +use OCP\Files\Storage\IStorage; +use OCP\WorkflowEngine\IEntity; + +trait TFileCheck { + /** @var IStorage */ + protected $storage; + + /** @var string */ + protected $path; + + /** @var bool */ + protected $isDir; + + /** + * @param IStorage $storage + * @param string $path + * @param bool $isDir + * @since 18.0.0 + */ + public function setFileInfo(IStorage $storage, string $path, bool $isDir = false): void { + $this->storage = $storage; + $this->path = $path; + $this->isDir = $isDir; + } + + /** + * @throws NotFoundException + */ + public function setEntitySubject(IEntity $entity, $subject): void { + if ($entity instanceof File) { + if (!$subject instanceof Node) { + throw new \UnexpectedValueException( + 'Expected Node subject for File entity, got {class}', + ['app' => Application::APP_ID, 'class' => get_class($subject)] + ); + } + $this->storage = $subject->getStorage(); + $this->path = $subject->getPath(); + } + } +} diff --git a/apps/workflowengine/lib/Check/UserGroupMembership.php b/apps/workflowengine/lib/Check/UserGroupMembership.php index fd6ba00d092..690f9974a49 100644 --- a/apps/workflowengine/lib/Check/UserGroupMembership.php +++ b/apps/workflowengine/lib/Check/UserGroupMembership.php @@ -1,33 +1,17 @@ <?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/>. - * + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ - namespace OCA\WorkflowEngine\Check; - -use OCP\Files\Storage\IStorage; use OCP\IGroupManager; use OCP\IL10N; use OCP\IUser; use OCP\IUserSession; use OCP\WorkflowEngine\ICheck; +use OCP\WorkflowEngine\IManager; class UserGroupMembership implements ICheck { @@ -37,32 +21,16 @@ class UserGroupMembership implements ICheck { /** @var string[] */ protected $cachedGroupMemberships; - /** @var IUserSession */ - protected $userSession; - - /** @var IGroupManager */ - protected $groupManager; - - /** @var IL10N */ - protected $l; - /** * @param IUserSession $userSession * @param IGroupManager $groupManager * @param IL10N $l */ - public function __construct(IUserSession $userSession, IGroupManager $groupManager, IL10N $l) { - $this->userSession = $userSession; - $this->groupManager = $groupManager; - $this->l = $l; - } - - /** - * @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. + public function __construct( + protected IUserSession $userSession, + protected IGroupManager $groupManager, + protected IL10N $l, + ) { } /** @@ -111,4 +79,14 @@ class UserGroupMembership implements ICheck { return $this->cachedGroupMemberships; } + + public function supportedEntities(): array { + // universal by default + return []; + } + + public function isAvailableForScope(int $scope): bool { + // admin only by default + return $scope === IManager::SCOPE_ADMIN; + } } diff --git a/apps/workflowengine/lib/Command/Index.php b/apps/workflowengine/lib/Command/Index.php new file mode 100644 index 00000000000..1fb8cb416b0 --- /dev/null +++ b/apps/workflowengine/lib/Command/Index.php @@ -0,0 +1,63 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\WorkflowEngine\Command; + +use OCA\WorkflowEngine\Helper\ScopeContext; +use OCA\WorkflowEngine\Manager; +use OCP\WorkflowEngine\IManager; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\OutputInterface; + +class Index extends Command { + + public function __construct( + private Manager $manager, + ) { + parent::__construct(); + } + + protected function configure() { + $this + ->setName('workflows:list') + ->setDescription('Lists configured workflows') + ->addArgument( + 'scope', + InputArgument::OPTIONAL, + 'Lists workflows for "admin", "user"', + 'admin' + ) + ->addArgument( + 'scopeId', + InputArgument::OPTIONAL, + 'User IDs when the scope is "user"', + null + ); + } + + protected function mappedScope(string $scope): int { + static $scopes = [ + 'admin' => IManager::SCOPE_ADMIN, + 'user' => IManager::SCOPE_USER, + ]; + return $scopes[$scope] ?? -1; + } + + protected function execute(InputInterface $input, OutputInterface $output): int { + $ops = $this->manager->getAllOperations( + new ScopeContext( + $this->mappedScope($input->getArgument('scope')), + $input->getArgument('scopeId') + ) + ); + $output->writeln(\json_encode($ops)); + return 0; + } +} diff --git a/apps/workflowengine/lib/Controller/AWorkflowController.php b/apps/workflowengine/lib/Controller/AWorkflowController.php new file mode 100644 index 00000000000..6395d0d98f6 --- /dev/null +++ b/apps/workflowengine/lib/Controller/AWorkflowController.php @@ -0,0 +1,151 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\WorkflowEngine\Controller; + +use Doctrine\DBAL\Exception; +use OCA\WorkflowEngine\Helper\ScopeContext; +use OCA\WorkflowEngine\Manager; +use OCP\AppFramework\Http\Attribute\PasswordConfirmationRequired; +use OCP\AppFramework\Http\DataResponse; +use OCP\AppFramework\OCS\OCSBadRequestException; +use OCP\AppFramework\OCS\OCSException; +use OCP\AppFramework\OCS\OCSForbiddenException; +use OCP\AppFramework\OCSController; +use OCP\IRequest; +use Psr\Log\LoggerInterface; + +abstract class AWorkflowController extends OCSController { + + public function __construct( + $appName, + IRequest $request, + protected Manager $manager, + private LoggerInterface $logger, + ) { + parent::__construct($appName, $request); + } + + /** + * @throws OCSForbiddenException + */ + abstract protected function getScopeContext(): ScopeContext; + + /** + * Example: curl -u joann -H "OCS-APIREQUEST: true" "http://my.nc.srvr/ocs/v2.php/apps/workflowengine/api/v1/workflows/global?format=json" + * + * @throws OCSForbiddenException + */ + public function index(): DataResponse { + $operationsByClass = $this->manager->getAllOperations($this->getScopeContext()); + + foreach ($operationsByClass as &$operations) { + foreach ($operations as &$operation) { + $operation = $this->manager->formatOperation($operation); + } + } + + return new DataResponse($operationsByClass); + } + + /** + * Example: curl -u joann -H "OCS-APIREQUEST: true" "http://my.nc.srvr/ocs/v2.php/apps/workflowengine/api/v1/workflows/global/OCA\\Workflow_DocToPdf\\Operation?format=json" + * + * @throws OCSForbiddenException + */ + public function show(string $id): DataResponse { + $context = $this->getScopeContext(); + + // The ID corresponds to a class name + $operations = $this->manager->getOperations($id, $context); + + foreach ($operations as &$operation) { + $operation = $this->manager->formatOperation($operation); + } + + return new DataResponse($operations); + } + + /** + * @throws OCSBadRequestException + * @throws OCSForbiddenException + * @throws OCSException + */ + #[PasswordConfirmationRequired] + public function create( + string $class, + string $name, + array $checks, + string $operation, + string $entity, + array $events, + ): DataResponse { + $context = $this->getScopeContext(); + try { + $operation = $this->manager->addOperation($class, $name, $checks, $operation, $context, $entity, $events); + $operation = $this->manager->formatOperation($operation); + return new DataResponse($operation); + } catch (\UnexpectedValueException $e) { + throw new OCSBadRequestException($e->getMessage(), $e); + } catch (\DomainException $e) { + throw new OCSForbiddenException($e->getMessage(), $e); + } catch (Exception $e) { + $this->logger->error('Error when inserting flow', ['exception' => $e]); + throw new OCSException('An internal error occurred', $e->getCode(), $e); + } + } + + /** + * @throws OCSBadRequestException + * @throws OCSForbiddenException + * @throws OCSException + */ + #[PasswordConfirmationRequired] + public function update( + int $id, + string $name, + array $checks, + string $operation, + string $entity, + array $events, + ): DataResponse { + try { + $context = $this->getScopeContext(); + $operation = $this->manager->updateOperation($id, $name, $checks, $operation, $context, $entity, $events); + $operation = $this->manager->formatOperation($operation); + return new DataResponse($operation); + } catch (\UnexpectedValueException $e) { + throw new OCSBadRequestException($e->getMessage(), $e); + } catch (\DomainException $e) { + throw new OCSForbiddenException($e->getMessage(), $e); + } catch (Exception $e) { + $this->logger->error('Error when updating flow with id ' . $id, ['exception' => $e]); + throw new OCSException('An internal error occurred', $e->getCode(), $e); + } + } + + /** + * @throws OCSBadRequestException + * @throws OCSForbiddenException + * @throws OCSException + */ + #[PasswordConfirmationRequired] + public function destroy(int $id): DataResponse { + try { + $deleted = $this->manager->deleteOperation($id, $this->getScopeContext()); + return new DataResponse($deleted); + } catch (\UnexpectedValueException $e) { + throw new OCSBadRequestException($e->getMessage(), $e); + } catch (\DomainException $e) { + throw new OCSForbiddenException($e->getMessage(), $e); + } catch (Exception $e) { + $this->logger->error('Error when deleting flow with id ' . $id, ['exception' => $e]); + throw new OCSException('An internal error occurred', $e->getCode(), $e); + } + } +} diff --git a/apps/workflowengine/lib/Controller/FlowOperations.php b/apps/workflowengine/lib/Controller/FlowOperations.php deleted file mode 100644 index 753aa2c26a7..00000000000 --- a/apps/workflowengine/lib/Controller/FlowOperations.php +++ /dev/null @@ -1,128 +0,0 @@ -<?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; - -class FlowOperations extends Controller { - - /** @var Manager */ - protected $manager; - - /** - * @param IRequest $request - * @param Manager $manager - */ - public function __construct(IRequest $request, Manager $manager) { - parent::__construct('workflowengine', $request); - $this->manager = $manager; - } - - /** - * @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); - } - - /** - * @PasswordConfirmationRequired - * - * @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); - } - } - - /** - * @PasswordConfirmationRequired - * - * @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); - } - } - - /** - * @PasswordConfirmationRequired - * - * @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/Controller/GlobalWorkflowsController.php b/apps/workflowengine/lib/Controller/GlobalWorkflowsController.php new file mode 100644 index 00000000000..001c673df35 --- /dev/null +++ b/apps/workflowengine/lib/Controller/GlobalWorkflowsController.php @@ -0,0 +1,25 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\WorkflowEngine\Controller; + +use OCA\WorkflowEngine\Helper\ScopeContext; +use OCP\WorkflowEngine\IManager; + +class GlobalWorkflowsController extends AWorkflowController { + + /** @var ScopeContext */ + private $scopeContext; + + protected function getScopeContext(): ScopeContext { + if ($this->scopeContext === null) { + $this->scopeContext = new ScopeContext(IManager::SCOPE_ADMIN); + } + return $this->scopeContext; + } +} diff --git a/apps/workflowengine/lib/Controller/RequestTime.php b/apps/workflowengine/lib/Controller/RequestTime.php deleted file mode 100644 index dd0efa89b91..00000000000 --- a/apps/workflowengine/lib/Controller/RequestTime.php +++ /dev/null @@ -1,52 +0,0 @@ -<?php -/** - * @copyright Copyright (c) 2016 Joas Schilling <coding@schilljs.com> - * - * @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 OCP\AppFramework\Controller; -use OCP\AppFramework\Http\JSONResponse; - -class RequestTime extends Controller { - - /** - * @NoAdminRequired - * - * @param string $search - * @return JSONResponse - */ - public function getTimezones($search = '') { - $timezones = \DateTimeZone::listIdentifiers(); - - if ($search !== '') { - $timezones = array_filter($timezones, function ($timezone) use ($search) { - return strpos(strtolower($timezone), strtolower($search)) !== false; - }); - } - - $timezones = array_slice($timezones, 0, 10); - - $response = []; - foreach ($timezones as $timezone) { - $response[$timezone] = $timezone; - } - return new JSONResponse($response); - } -} diff --git a/apps/workflowengine/lib/Controller/RequestTimeController.php b/apps/workflowengine/lib/Controller/RequestTimeController.php new file mode 100644 index 00000000000..4b34f16ce0a --- /dev/null +++ b/apps/workflowengine/lib/Controller/RequestTimeController.php @@ -0,0 +1,37 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\WorkflowEngine\Controller; + +use OCP\AppFramework\Controller; +use OCP\AppFramework\Http\Attribute\NoAdminRequired; +use OCP\AppFramework\Http\JSONResponse; + +class RequestTimeController extends Controller { + + /** + * @param string $search + * @return JSONResponse + */ + #[NoAdminRequired] + public function getTimezones($search = '') { + $timezones = \DateTimeZone::listIdentifiers(); + + if ($search !== '') { + $timezones = array_filter($timezones, function ($timezone) use ($search) { + return stripos($timezone, $search) !== false; + }); + } + + $timezones = array_slice($timezones, 0, 10); + + $response = []; + foreach ($timezones as $timezone) { + $response[$timezone] = $timezone; + } + return new JSONResponse($response); + } +} diff --git a/apps/workflowengine/lib/Controller/UserWorkflowsController.php b/apps/workflowengine/lib/Controller/UserWorkflowsController.php new file mode 100644 index 00000000000..953ce149233 --- /dev/null +++ b/apps/workflowengine/lib/Controller/UserWorkflowsController.php @@ -0,0 +1,101 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\WorkflowEngine\Controller; + +use OCA\WorkflowEngine\Helper\ScopeContext; +use OCA\WorkflowEngine\Manager; +use OCP\AppFramework\Http\Attribute\NoAdminRequired; +use OCP\AppFramework\Http\Attribute\PasswordConfirmationRequired; +use OCP\AppFramework\Http\DataResponse; +use OCP\AppFramework\OCS\OCSBadRequestException; +use OCP\AppFramework\OCS\OCSForbiddenException; +use OCP\IRequest; +use OCP\IUserSession; +use OCP\WorkflowEngine\IManager; +use Psr\Log\LoggerInterface; + +class UserWorkflowsController extends AWorkflowController { + + /** @var ScopeContext */ + private $scopeContext; + + public function __construct( + $appName, + IRequest $request, + Manager $manager, + private IUserSession $session, + LoggerInterface $logger, + ) { + parent::__construct($appName, $request, $manager, $logger); + } + + /** + * Retrieve all configured workflow rules + * + * Example: curl -u joann -H "OCS-APIREQUEST: true" "http://my.nc.srvr/ocs/v2.php/apps/workflowengine/api/v1/workflows/user?format=json" + * + * @throws OCSForbiddenException + */ + #[NoAdminRequired] + public function index(): DataResponse { + return parent::index(); + } + + /** + * Example: curl -u joann -H "OCS-APIREQUEST: true" "http://my.nc.srvr/ocs/v2.php/apps/workflowengine/api/v1/workflows/user/OCA\\Workflow_DocToPdf\\Operation?format=json" + * @throws OCSForbiddenException + */ + #[NoAdminRequired] + public function show(string $id): DataResponse { + return parent::show($id); + } + + /** + * @throws OCSBadRequestException + * @throws OCSForbiddenException + */ + #[NoAdminRequired] + #[PasswordConfirmationRequired] + public function create(string $class, string $name, array $checks, string $operation, string $entity, array $events): DataResponse { + return parent::create($class, $name, $checks, $operation, $entity, $events); + } + + /** + * @throws OCSBadRequestException + * @throws OCSForbiddenException + */ + #[NoAdminRequired] + #[PasswordConfirmationRequired] + public function update(int $id, string $name, array $checks, string $operation, string $entity, array $events): DataResponse { + return parent::update($id, $name, $checks, $operation, $entity, $events); + } + + /** + * @throws OCSForbiddenException + */ + #[NoAdminRequired] + #[PasswordConfirmationRequired] + public function destroy(int $id): DataResponse { + return parent::destroy($id); + } + + /** + * @throws OCSForbiddenException + */ + protected function getScopeContext(): ScopeContext { + if ($this->scopeContext === null) { + $user = $this->session->getUser(); + if (!$user || !$this->manager->isUserScopeEnabled()) { + throw new OCSForbiddenException('User not logged in'); + } + $this->scopeContext = new ScopeContext(IManager::SCOPE_USER, $user->getUID()); + } + return $this->scopeContext; + } +} diff --git a/apps/workflowengine/lib/Entity/File.php b/apps/workflowengine/lib/Entity/File.php new file mode 100644 index 00000000000..64d552e1737 --- /dev/null +++ b/apps/workflowengine/lib/Entity/File.php @@ -0,0 +1,256 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\WorkflowEngine\Entity; + +use OC\Files\Config\UserMountCache; +use OCP\EventDispatcher\Event; +use OCP\EventDispatcher\GenericEvent; +use OCP\Files\InvalidPathException; +use OCP\Files\IRootFolder; +use OCP\Files\Mount\IMountManager; +use OCP\Files\Node; +use OCP\Files\NotFoundException; +use OCP\IL10N; +use OCP\IURLGenerator; +use OCP\IUser; +use OCP\IUserManager; +use OCP\IUserSession; +use OCP\SystemTag\ISystemTag; +use OCP\SystemTag\ISystemTagManager; +use OCP\SystemTag\MapperEvent; +use OCP\WorkflowEngine\EntityContext\IContextPortation; +use OCP\WorkflowEngine\EntityContext\IDisplayText; +use OCP\WorkflowEngine\EntityContext\IIcon; +use OCP\WorkflowEngine\EntityContext\IUrl; +use OCP\WorkflowEngine\GenericEntityEvent; +use OCP\WorkflowEngine\IEntity; +use OCP\WorkflowEngine\IRuleMatcher; + +class File implements IEntity, IDisplayText, IUrl, IIcon, IContextPortation { + private const EVENT_NAMESPACE = '\OCP\Files::'; + /** @var string */ + protected $eventName; + /** @var Event */ + protected $event; + /** @var ?Node */ + private $node; + /** @var ?IUser */ + private $actingUser = null; + /** @var UserMountCache */ + private $userMountCache; + + public function __construct( + protected IL10N $l10n, + protected IURLGenerator $urlGenerator, + protected IRootFolder $root, + private IUserSession $userSession, + private ISystemTagManager $tagManager, + private IUserManager $userManager, + UserMountCache $userMountCache, + private IMountManager $mountManager, + ) { + $this->userMountCache = $userMountCache; + } + + public function getName(): string { + return $this->l10n->t('File'); + } + + public function getIcon(): string { + return $this->urlGenerator->imagePath('core', 'categories/files.svg'); + } + + public function getEvents(): array { + return [ + new GenericEntityEvent($this->l10n->t('File created'), self::EVENT_NAMESPACE . 'postCreate'), + new GenericEntityEvent($this->l10n->t('File updated'), self::EVENT_NAMESPACE . 'postWrite'), + new GenericEntityEvent($this->l10n->t('File renamed'), self::EVENT_NAMESPACE . 'postRename'), + new GenericEntityEvent($this->l10n->t('File deleted'), self::EVENT_NAMESPACE . 'postDelete'), + new GenericEntityEvent($this->l10n->t('File accessed'), self::EVENT_NAMESPACE . 'postTouch'), + new GenericEntityEvent($this->l10n->t('File copied'), self::EVENT_NAMESPACE . 'postCopy'), + new GenericEntityEvent($this->l10n->t('Tag assigned'), MapperEvent::EVENT_ASSIGN), + ]; + } + + public function prepareRuleMatcher(IRuleMatcher $ruleMatcher, string $eventName, Event $event): void { + if (!$event instanceof GenericEvent && !$event instanceof MapperEvent) { + return; + } + $this->eventName = $eventName; + $this->event = $event; + $this->actingUser = $this->actingUser ?? $this->userSession->getUser(); + try { + $node = $this->getNode(); + $ruleMatcher->setEntitySubject($this, $node); + $ruleMatcher->setFileInfo($node->getStorage(), $node->getInternalPath()); + } catch (NotFoundException $e) { + // pass + } + } + + public function isLegitimatedForUserId(string $userId): bool { + try { + $node = $this->getNode(); + if ($node->getOwner()?->getUID() === $userId) { + return true; + } + + if ($this->eventName === self::EVENT_NAMESPACE . 'postDelete') { + // At postDelete, the file no longer exists. Check for parent folder instead. + $fileId = $node->getParentId(); + } else { + $fileId = $node->getId(); + } + + $mountInfos = $this->userMountCache->getMountsForFileId($fileId, $userId); + foreach ($mountInfos as $mountInfo) { + $mount = $this->mountManager->getMountFromMountInfo($mountInfo); + if ($mount && $mount->getStorage() && !empty($mount->getStorage()->getCache()->get($fileId))) { + return true; + } + } + return false; + } catch (NotFoundException $e) { + return false; + } + } + + /** + * @throws NotFoundException + */ + protected function getNode(): Node { + if ($this->node) { + return $this->node; + } + if (!$this->event instanceof GenericEvent && !$this->event instanceof MapperEvent) { + throw new NotFoundException(); + } + switch ($this->eventName) { + case self::EVENT_NAMESPACE . 'postCreate': + case self::EVENT_NAMESPACE . 'postWrite': + case self::EVENT_NAMESPACE . 'postDelete': + case self::EVENT_NAMESPACE . 'postTouch': + return $this->event->getSubject(); + case self::EVENT_NAMESPACE . 'postRename': + case self::EVENT_NAMESPACE . 'postCopy': + return $this->event->getSubject()[1]; + case MapperEvent::EVENT_ASSIGN: + if (!$this->event instanceof MapperEvent || $this->event->getObjectType() !== 'files') { + throw new NotFoundException(); + } + $nodes = $this->root->getById((int)$this->event->getObjectId()); + if (is_array($nodes) && isset($nodes[0])) { + $this->node = $nodes[0]; + return $this->node; + } + break; + } + throw new NotFoundException(); + } + + public function getDisplayText(int $verbosity = 0): string { + try { + $node = $this->getNode(); + } catch (NotFoundException $e) { + return ''; + } + + $options = [ + $this->actingUser ? $this->actingUser->getDisplayName() : $this->l10n->t('Someone'), + $node->getName() + ]; + + switch ($this->eventName) { + case self::EVENT_NAMESPACE . 'postCreate': + return $this->l10n->t('%s created %s', $options); + case self::EVENT_NAMESPACE . 'postWrite': + return $this->l10n->t('%s modified %s', $options); + case self::EVENT_NAMESPACE . 'postDelete': + return $this->l10n->t('%s deleted %s', $options); + case self::EVENT_NAMESPACE . 'postTouch': + return $this->l10n->t('%s accessed %s', $options); + case self::EVENT_NAMESPACE . 'postRename': + return $this->l10n->t('%s renamed %s', $options); + case self::EVENT_NAMESPACE . 'postCopy': + return $this->l10n->t('%s copied %s', $options); + case MapperEvent::EVENT_ASSIGN: + $tagNames = []; + if ($this->event instanceof MapperEvent) { + $tagIDs = $this->event->getTags(); + $tagObjects = $this->tagManager->getTagsByIds($tagIDs); + foreach ($tagObjects as $systemTag) { + /** @var ISystemTag $systemTag */ + if ($systemTag->isUserVisible()) { + $tagNames[] = $systemTag->getName(); + } + } + } + $filename = array_pop($options); + $tagString = implode(', ', $tagNames); + if ($tagString === '') { + return ''; + } + array_push($options, $tagString, $filename); + return $this->l10n->t('%s assigned %s to %s', $options); + } + } + + public function getUrl(): string { + try { + return $this->urlGenerator->linkToRouteAbsolute('files.viewcontroller.showFile', ['fileid' => $this->getNode()->getId()]); + } catch (InvalidPathException $e) { + return ''; + } catch (NotFoundException $e) { + return ''; + } + } + + /** + * @inheritDoc + */ + public function exportContextIDs(): array { + $nodeOwner = $this->getNode()->getOwner(); + $actingUserId = null; + if ($this->actingUser instanceof IUser) { + $actingUserId = $this->actingUser->getUID(); + } elseif ($this->userSession->getUser() instanceof IUser) { + $actingUserId = $this->userSession->getUser()->getUID(); + } + return [ + 'eventName' => $this->eventName, + 'nodeId' => $this->getNode()->getId(), + 'nodeOwnerId' => $nodeOwner ? $nodeOwner->getUID() : null, + 'actingUserId' => $actingUserId, + ]; + } + + /** + * @inheritDoc + */ + public function importContextIDs(array $contextIDs): void { + $this->eventName = $contextIDs['eventName']; + if ($contextIDs['nodeOwnerId'] !== null) { + $userFolder = $this->root->getUserFolder($contextIDs['nodeOwnerId']); + $nodes = $userFolder->getById($contextIDs['nodeId']); + } else { + $nodes = $this->root->getById($contextIDs['nodeId']); + } + $this->node = $nodes[0] ?? null; + if ($contextIDs['actingUserId']) { + $this->actingUser = $this->userManager->get($contextIDs['actingUserId']); + } + } + + /** + * @inheritDoc + */ + public function getIconUrl(): string { + return $this->getIcon(); + } +} diff --git a/apps/workflowengine/lib/Helper/LogContext.php b/apps/workflowengine/lib/Helper/LogContext.php new file mode 100644 index 00000000000..9d740680bb6 --- /dev/null +++ b/apps/workflowengine/lib/Helper/LogContext.php @@ -0,0 +1,79 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\WorkflowEngine\Helper; + +use OCP\WorkflowEngine\IEntity; +use OCP\WorkflowEngine\IManager; +use OCP\WorkflowEngine\IOperation; + +class LogContext { + /** @var array */ + protected $details; + + public function setDescription(string $description): LogContext { + $this->details['message'] = $description; + return $this; + } + + public function setScopes(array $scopes): LogContext { + $this->details['scopes'] = []; + foreach ($scopes as $scope) { + if ($scope instanceof ScopeContext) { + switch ($scope->getScope()) { + case IManager::SCOPE_ADMIN: + $this->details['scopes'][] = ['scope' => 'admin']; + break; + case IManager::SCOPE_USER: + $this->details['scopes'][] = [ + 'scope' => 'user', + 'uid' => $scope->getScopeId(), + ]; + break; + default: + continue 2; + } + } + } + return $this; + } + + public function setOperation(?IOperation $operation): LogContext { + if ($operation instanceof IOperation) { + $this->details['operation'] = [ + 'class' => get_class($operation), + 'name' => $operation->getDisplayName(), + ]; + } + return $this; + } + + public function setEntity(?IEntity $entity): LogContext { + if ($entity instanceof IEntity) { + $this->details['entity'] = [ + 'class' => get_class($entity), + 'name' => $entity->getName(), + ]; + } + return $this; + } + + public function setConfiguration(array $configuration): LogContext { + $this->details['configuration'] = $configuration; + return $this; + } + + public function setEventName(string $eventName): LogContext { + $this->details['eventName'] = $eventName; + return $this; + } + + public function getDetails(): array { + return $this->details; + } +} diff --git a/apps/workflowengine/lib/Helper/ScopeContext.php b/apps/workflowengine/lib/Helper/ScopeContext.php new file mode 100644 index 00000000000..05379f5ff43 --- /dev/null +++ b/apps/workflowengine/lib/Helper/ScopeContext.php @@ -0,0 +1,61 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\WorkflowEngine\Helper; + +use OCP\WorkflowEngine\IManager; + +class ScopeContext { + /** @var int */ + private $scope; + /** @var string */ + private $scopeId; + /** @var string */ + private $hash; + + public function __construct(int $scope, ?string $scopeId = null) { + $this->scope = $this->evaluateScope($scope); + $this->scopeId = $this->evaluateScopeId($scopeId); + } + + private function evaluateScope(int $scope): int { + if (in_array($scope, [IManager::SCOPE_ADMIN, IManager::SCOPE_USER], true)) { + return $scope; + } + throw new \InvalidArgumentException('Invalid scope'); + } + + private function evaluateScopeId(?string $scopeId = null): string { + if ($this->scope === IManager::SCOPE_USER + && trim((string)$scopeId) === '') { + throw new \InvalidArgumentException('user scope requires a user id'); + } + return trim((string)$scopeId); + } + + /** + * @return int + */ + public function getScope(): int { + return $this->scope; + } + + /** + * @return string + */ + public function getScopeId(): string { + return $this->scopeId; + } + + public function getHash(): string { + if ($this->hash === null) { + $this->hash = \hash('sha256', $this->getScope() . '::' . $this->getScopeId()); + } + return $this->hash; + } +} diff --git a/apps/workflowengine/lib/Listener/LoadAdditionalSettingsScriptsListener.php b/apps/workflowengine/lib/Listener/LoadAdditionalSettingsScriptsListener.php new file mode 100644 index 00000000000..e5a03fdcb2e --- /dev/null +++ b/apps/workflowengine/lib/Listener/LoadAdditionalSettingsScriptsListener.php @@ -0,0 +1,26 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\WorkflowEngine\Listener; + +use OCA\WorkflowEngine\AppInfo\Application; +use OCP\EventDispatcher\Event; +use OCP\EventDispatcher\IEventListener; +use OCP\Util; +use OCP\WorkflowEngine\Events\LoadSettingsScriptsEvent; + +/** @template-implements IEventListener<LoadSettingsScriptsEvent> */ +class LoadAdditionalSettingsScriptsListener implements IEventListener { + public function handle(Event $event): void { + Util::addScript('core', 'files_fileinfo'); + Util::addScript('core', 'files_client'); + Util::addScript('core', 'systemtags'); + Util::addScript(Application::APP_ID, 'workflowengine'); + } +} diff --git a/apps/workflowengine/lib/Manager.php b/apps/workflowengine/lib/Manager.php index 48d29cf207e..0f41679789d 100644 --- a/apps/workflowengine/lib/Manager.php +++ b/apps/workflowengine/lib/Manager.php @@ -1,150 +1,215 @@ <?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/>. - * + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ - namespace OCA\WorkflowEngine; - +use Doctrine\DBAL\Exception; +use OCA\WorkflowEngine\AppInfo\Application; +use OCA\WorkflowEngine\Check\FileMimeType; +use OCA\WorkflowEngine\Check\FileName; +use OCA\WorkflowEngine\Check\FileSize; +use OCA\WorkflowEngine\Check\FileSystemTags; +use OCA\WorkflowEngine\Check\RequestRemoteAddress; +use OCA\WorkflowEngine\Check\RequestTime; +use OCA\WorkflowEngine\Check\RequestURL; +use OCA\WorkflowEngine\Check\RequestUserAgent; +use OCA\WorkflowEngine\Check\UserGroupMembership; +use OCA\WorkflowEngine\Entity\File; +use OCA\WorkflowEngine\Helper\ScopeContext; +use OCA\WorkflowEngine\Service\Logger; +use OCA\WorkflowEngine\Service\RuleMatcher; use OCP\AppFramework\QueryException; +use OCP\Cache\CappedMemoryCache; use OCP\DB\QueryBuilder\IQueryBuilder; -use OCP\Files\Storage\IStorage; +use OCP\EventDispatcher\IEventDispatcher; +use OCP\ICacheFactory; +use OCP\IConfig; use OCP\IDBConnection; use OCP\IL10N; use OCP\IServerContainer; +use OCP\IUserSession; +use OCP\WorkflowEngine\Events\RegisterChecksEvent; +use OCP\WorkflowEngine\Events\RegisterEntitiesEvent; +use OCP\WorkflowEngine\Events\RegisterOperationsEvent; use OCP\WorkflowEngine\ICheck; +use OCP\WorkflowEngine\IComplexOperation; +use OCP\WorkflowEngine\IEntity; +use OCP\WorkflowEngine\IEntityEvent; use OCP\WorkflowEngine\IManager; use OCP\WorkflowEngine\IOperation; +use OCP\WorkflowEngine\IRuleMatcher; +use Psr\Log\LoggerInterface; 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 IEntity[] */ + protected $registeredEntities = []; + + /** @var IOperation[] */ + protected $registeredOperators = []; + + /** @var ICheck[] */ + protected $registeredChecks = []; + + /** @var CappedMemoryCache<int[]> */ + protected CappedMemoryCache $operationsByScope; + + public function __construct( + protected IDBConnection $connection, + protected IServerContainer $container, + protected IL10N $l, + protected LoggerInterface $logger, + protected IUserSession $session, + private IEventDispatcher $dispatcher, + private IConfig $config, + private ICacheFactory $cacheFactory, + ) { + $this->operationsByScope = new CappedMemoryCache(64); + } - /** @var IServerContainer|\OC\Server */ - protected $container; + public function getRuleMatcher(): IRuleMatcher { + return new RuleMatcher( + $this->session, + $this->container, + $this->l, + $this, + $this->container->query(Logger::class) + ); + } - /** @var IL10N */ - protected $l; + public function getAllConfiguredEvents() { + $cache = $this->cacheFactory->createDistributed('flow'); + $cached = $cache->get('events'); + if ($cached !== null) { + return $cached; + } - /** - * @param IDBConnection $connection - * @param IServerContainer $container - * @param IL10N $l - */ - public function __construct(IDBConnection $connection, IServerContainer $container, IL10N $l) { - $this->connection = $connection; - $this->container = $container; - $this->l = $l; - } + $query = $this->connection->getQueryBuilder(); - /** - * @inheritdoc - */ - public function setFileInfo(IStorage $storage, $path) { - $this->storage = $storage; - $this->path = $path; - } + $query->select('class', 'entity') + ->selectAlias($query->expr()->castColumn('events', IQueryBuilder::PARAM_STR), 'events') + ->from('flow_operations') + ->where($query->expr()->neq('events', $query->createNamedParameter('[]'), IQueryBuilder::PARAM_STR)) + ->groupBy('class', 'entity', $query->expr()->castColumn('events', IQueryBuilder::PARAM_STR)); - /** - * @inheritdoc - */ - public function getMatchingOperations($class, $returnFirstMatchingOperationOnly = true) { - $operations = $this->getOperations($class); + $result = $query->executeQuery(); + $operations = []; + while ($row = $result->fetch()) { + $eventNames = \json_decode($row['events']); - $matches = []; - foreach ($operations as $operation) { - $checkIds = json_decode($operation['checks'], true); - $checks = $this->getChecks($checkIds); + $operation = $row['class']; + $entity = $row['entity']; - foreach ($checks as $check) { - if (!$this->check($check)) { - // Check did not match, continue with the next operation - continue 2; - } - } + $operations[$operation] = $operations[$row['class']] ?? []; + $operations[$operation][$entity] = $operations[$operation][$entity] ?? []; - if ($returnFirstMatchingOperationOnly) { - return $operation; - } - $matches[] = $operation; + $operations[$operation][$entity] = array_unique(array_merge($operations[$operation][$entity], $eventNames ?? [])); } + $result->closeCursor(); + + $cache->set('events', $operations, 3600); - return $matches; + return $operations; } /** - * @param array $check - * @return bool + * @param string $operationClass + * @return ScopeContext[] */ - protected function check(array $check) { + public function getAllConfiguredScopesForOperation(string $operationClass): array { + static $scopesByOperation = []; + if (isset($scopesByOperation[$operationClass])) { + return $scopesByOperation[$operationClass]; + } + try { - $checkInstance = $this->container->query($check['class']); + /** @var IOperation $operation */ + $operation = $this->container->query($operationClass); } catch (QueryException $e) { - // Check does not exist, assume it matches. - return true; + return []; } - if ($checkInstance instanceof ICheck) { - $checkInstance->setFileInfo($this->storage, $this->path); - return $checkInstance->executeCheck($check['operator'], $check['value']); - } else { - // Check is invalid - throw new \UnexpectedValueException($this->l->t('Check %s is invalid or does not exist', $check['class'])); + $query = $this->connection->getQueryBuilder(); + + $query->selectDistinct('s.type') + ->addSelect('s.value') + ->from('flow_operations', 'o') + ->leftJoin('o', 'flow_operations_scope', 's', $query->expr()->eq('o.id', 's.operation_id')) + ->where($query->expr()->eq('o.class', $query->createParameter('operationClass'))); + + $query->setParameters(['operationClass' => $operationClass]); + $result = $query->executeQuery(); + + $scopesByOperation[$operationClass] = []; + while ($row = $result->fetch()) { + $scope = new ScopeContext($row['type'], $row['value']); + + if (!$operation->isAvailableForScope((int)$row['type'])) { + continue; + } + + $scopesByOperation[$operationClass][$scope->getHash()] = $scope; } + + return $scopesByOperation[$operationClass]; } - /** - * @param string $class - * @return array[] - */ - public function getOperations($class) { - if (isset($this->operations[$class])) { - return $this->operations[$class]; + public function getAllOperations(ScopeContext $scopeContext): array { + if (isset($this->operations[$scopeContext->getHash()])) { + return $this->operations[$scopeContext->getHash()]; } $query = $this->connection->getQueryBuilder(); - $query->select('*') - ->from('flow_operations') - ->where($query->expr()->eq('class', $query->createNamedParameter($class))); - $result = $query->execute(); + $query->select('o.*') + ->selectAlias('s.type', 'scope_type') + ->selectAlias('s.value', 'scope_actor_id') + ->from('flow_operations', 'o') + ->leftJoin('o', 'flow_operations_scope', 's', $query->expr()->eq('o.id', 's.operation_id')) + ->where($query->expr()->eq('s.type', $query->createParameter('scope'))); + + if ($scopeContext->getScope() === IManager::SCOPE_USER) { + $query->andWhere($query->expr()->eq('s.value', $query->createParameter('scopeId'))); + } + + $query->setParameters(['scope' => $scopeContext->getScope(), 'scopeId' => $scopeContext->getScopeId()]); + $result = $query->executeQuery(); - $this->operations[$class] = []; + $this->operations[$scopeContext->getHash()] = []; while ($row = $result->fetch()) { - $this->operations[$class][] = $row; + try { + /** @var IOperation $operation */ + $operation = $this->container->query($row['class']); + } catch (QueryException $e) { + continue; + } + + if (!$operation->isAvailableForScope((int)$row['scope_type'])) { + continue; + } + + if (!isset($this->operations[$scopeContext->getHash()][$row['class']])) { + $this->operations[$scopeContext->getHash()][$row['class']] = []; + } + $this->operations[$scopeContext->getHash()][$row['class']][] = $row; } - $result->closeCursor(); - return $this->operations[$class]; + return $this->operations[$scopeContext->getHash()]; + } + + public function getOperations(string $class, ScopeContext $scopeContext): array { + if (!isset($this->operations[$scopeContext->getHash()])) { + $this->getAllOperations($scopeContext); + } + return $this->operations[$scopeContext->getHash()][$class] ?? []; } /** @@ -157,7 +222,7 @@ class Manager implements IManager { $query->select('*') ->from('flow_operations') ->where($query->expr()->eq('id', $query->createNamedParameter($id))); - $result = $query->execute(); + $result = $query->executeQuery(); $row = $result->fetch(); $result->closeCursor(); @@ -168,6 +233,31 @@ class Manager implements IManager { throw new \UnexpectedValueException($this->l->t('Operation #%s does not exist', [$id])); } + protected function insertOperation( + string $class, + string $name, + array $checkIds, + string $operation, + string $entity, + array $events, + ): int { + $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), + 'entity' => $query->createNamedParameter($entity), + 'events' => $query->createNamedParameter(json_encode($events)) + ]); + $query->executeStatement(); + + $this->cacheFactory->createDistributed('flow')->remove('events'); + + return $query->getLastInsertId(); + } + /** * @param string $class * @param string $name @@ -175,29 +265,67 @@ class Manager implements IManager { * @param string $operation * @return array The added operation * @throws \UnexpectedValueException + * @throws Exception */ - public function addOperation($class, $name, array $checks, $operation) { - $this->validateOperation($class, $name, $checks, $operation); + public function addOperation( + string $class, + string $name, + array $checks, + string $operation, + ScopeContext $scope, + string $entity, + array $events, + ) { + $this->validateOperation($class, $name, $checks, $operation, $scope, $entity, $events); + + $this->connection->beginTransaction(); - $checkIds = []; - foreach ($checks as $check) { - $checkIds[] = $this->addCheck($check['class'], $check['operator'], $check['value']); - } + try { + $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 = $this->insertOperation($class, $name, $checkIds, $operation, $entity, $events); + $this->addScope($id, $scope); + + $this->connection->commit(); + } catch (Exception $e) { + $this->connection->rollBack(); + throw $e; + } - $id = $query->getLastInsertId(); return $this->getOperation($id); } + protected function canModify(int $id, ScopeContext $scopeContext):bool { + if (isset($this->operationsByScope[$scopeContext->getHash()])) { + return in_array($id, $this->operationsByScope[$scopeContext->getHash()], true); + } + + $qb = $this->connection->getQueryBuilder(); + $qb = $qb->select('o.id') + ->from('flow_operations', 'o') + ->leftJoin('o', 'flow_operations_scope', 's', $qb->expr()->eq('o.id', 's.operation_id')) + ->where($qb->expr()->eq('s.type', $qb->createParameter('scope'))); + + if ($scopeContext->getScope() !== IManager::SCOPE_ADMIN) { + $qb->andWhere($qb->expr()->eq('s.value', $qb->createParameter('scopeId'))); + } + + $qb->setParameters(['scope' => $scopeContext->getScope(), 'scopeId' => $scopeContext->getScopeId()]); + $result = $qb->executeQuery(); + + $operations = []; + while (($opId = $result->fetchOne()) !== false) { + $operations[] = (int)$opId; + } + $this->operationsByScope[$scopeContext->getHash()] = $operations; + $result->closeCursor(); + + return in_array($id, $this->operationsByScope[$scopeContext->getHash()], true); + } + /** * @param int $id * @param string $name @@ -205,23 +333,47 @@ class Manager implements IManager { * @param string $operation * @return array The updated operation * @throws \UnexpectedValueException + * @throws \DomainException + * @throws Exception */ - public function updateOperation($id, $name, array $checks, $operation) { + public function updateOperation( + int $id, + string $name, + array $checks, + string $operation, + ScopeContext $scopeContext, + string $entity, + array $events, + ): array { + if (!$this->canModify($id, $scopeContext)) { + throw new \DomainException('Target operation not within scope'); + }; $row = $this->getOperation($id); - $this->validateOperation($row['class'], $name, $checks, $operation); + $this->validateOperation($row['class'], $name, $checks, $operation, $scopeContext, $entity, $events); $checkIds = []; - foreach ($checks as $check) { - $checkIds[] = $this->addCheck($check['class'], $check['operator'], $check['value']); - } + try { + $this->connection->beginTransaction(); + 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(); + $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)) + ->set('entity', $query->createNamedParameter($entity)) + ->set('events', $query->createNamedParameter(json_encode($events))) + ->where($query->expr()->eq('id', $query->createNamedParameter($id))); + $query->execute(); + $this->connection->commit(); + } catch (Exception $e) { + $this->connection->rollBack(); + throw $e; + } + unset($this->operations[$scopeContext->getHash()]); + $this->cacheFactory->createDistributed('flow')->remove('events'); return $this->getOperation($id); } @@ -230,12 +382,69 @@ class Manager implements IManager { * @param int $id * @return bool * @throws \UnexpectedValueException + * @throws Exception + * @throws \DomainException */ - public function deleteOperation($id) { + public function deleteOperation($id, ScopeContext $scopeContext) { + if (!$this->canModify($id, $scopeContext)) { + throw new \DomainException('Target operation not within scope'); + }; $query = $this->connection->getQueryBuilder(); - $query->delete('flow_operations') - ->where($query->expr()->eq('id', $query->createNamedParameter($id))); - return (bool) $query->execute(); + try { + $this->connection->beginTransaction(); + $result = (bool)$query->delete('flow_operations') + ->where($query->expr()->eq('id', $query->createNamedParameter($id))) + ->executeStatement(); + if ($result) { + $qb = $this->connection->getQueryBuilder(); + $result &= (bool)$qb->delete('flow_operations_scope') + ->where($qb->expr()->eq('operation_id', $qb->createNamedParameter($id))) + ->executeStatement(); + } + $this->connection->commit(); + } catch (Exception $e) { + $this->connection->rollBack(); + throw $e; + } + + if (isset($this->operations[$scopeContext->getHash()])) { + unset($this->operations[$scopeContext->getHash()]); + } + + $this->cacheFactory->createDistributed('flow')->remove('events'); + + return $result; + } + + protected function validateEvents(string $entity, array $events, IOperation $operation) { + try { + /** @var IEntity $instance */ + $instance = $this->container->query($entity); + } catch (QueryException $e) { + throw new \UnexpectedValueException($this->l->t('Entity %s does not exist', [$entity])); + } + + if (!$instance instanceof IEntity) { + throw new \UnexpectedValueException($this->l->t('Entity %s is invalid', [$entity])); + } + + if (empty($events)) { + if (!$operation instanceof IComplexOperation) { + throw new \UnexpectedValueException($this->l->t('No events are chosen.')); + } + return; + } + + $availableEvents = []; + foreach ($instance->getEvents() as $event) { + /** @var IEntityEvent $event */ + $availableEvents[] = $event->getEventName(); + } + + $diff = array_diff($events, $availableEvents); + if (!empty($diff)) { + throw new \UnexpectedValueException($this->l->t('Entity %s has no event %s', [$entity, array_shift($diff)])); + } } /** @@ -243,9 +452,12 @@ class Manager implements IManager { * @param string $name * @param array[] $checks * @param string $operation + * @param ScopeContext $scope + * @param string $entity + * @param array $events * @throws \UnexpectedValueException */ - protected function validateOperation($class, $name, array $checks, $operation) { + public function validateOperation($class, $name, array $checks, $operation, ScopeContext $scope, string $entity, array $events) { try { /** @var IOperation $instance */ $instance = $this->container->query($class); @@ -257,9 +469,27 @@ class Manager implements IManager { throw new \UnexpectedValueException($this->l->t('Operation %s is invalid', [$class])); } + if (!$instance->isAvailableForScope($scope->getScope())) { + throw new \UnexpectedValueException($this->l->t('Operation %s is invalid', [$class])); + } + + $this->validateEvents($entity, $events, $instance); + + if (count($checks) === 0) { + throw new \UnexpectedValueException($this->l->t('At least one check needs to be provided')); + } + + if (strlen((string)$operation) > IManager::MAX_OPERATION_VALUE_BYTES) { + throw new \UnexpectedValueException($this->l->t('The provided operation data is too long')); + } + $instance->validateOperation($name, $checks, $operation); foreach ($checks as $check) { + if (!is_string($check['class'])) { + throw new \UnexpectedValueException($this->l->t('Invalid check provided')); + } + try { /** @var ICheck $instance */ $instance = $this->container->query($check['class']); @@ -271,6 +501,16 @@ class Manager implements IManager { throw new \UnexpectedValueException($this->l->t('Check %s is invalid', [$class])); } + if (!empty($instance->supportedEntities()) + && !in_array($entity, $instance->supportedEntities()) + ) { + throw new \UnexpectedValueException($this->l->t('Check %s is not allowed with this entity', [$class])); + } + + if (strlen((string)$check['value']) > IManager::MAX_CHECK_VALUE_BYTES) { + throw new \UnexpectedValueException($this->l->t('The provided check value is too long')); + } + $instance->validateCheck($check['operator'], $check['value']); } } @@ -298,11 +538,11 @@ class Manager implements IManager { $query->select('*') ->from('flow_checks') ->where($query->expr()->in('id', $query->createNamedParameter($checkIds, IQueryBuilder::PARAM_INT_ARRAY))); - $result = $query->execute(); + $result = $query->executeQuery(); while ($row = $result->fetch()) { - $this->checks[(int) $row['id']] = $row; - $checks[(int) $row['id']] = $row; + $this->checks[(int)$row['id']] = $row; + $checks[(int)$row['id']] = $row; } $result->closeCursor(); @@ -329,11 +569,11 @@ class Manager implements IManager { $query->select('id') ->from('flow_checks') ->where($query->expr()->eq('hash', $query->createNamedParameter($hash))); - $result = $query->execute(); + $result = $query->executeQuery(); if ($row = $result->fetch()) { $result->closeCursor(); - return (int) $row['id']; + return (int)$row['id']; } $query = $this->connection->getQueryBuilder(); @@ -344,8 +584,131 @@ class Manager implements IManager { 'value' => $query->createNamedParameter($value), 'hash' => $query->createNamedParameter($hash), ]); - $query->execute(); + $query->executeStatement(); return $query->getLastInsertId(); } + + protected function addScope(int $operationId, ScopeContext $scope): void { + $query = $this->connection->getQueryBuilder(); + + $insertQuery = $query->insert('flow_operations_scope'); + $insertQuery->values([ + 'operation_id' => $query->createNamedParameter($operationId), + 'type' => $query->createNamedParameter($scope->getScope()), + 'value' => $query->createNamedParameter($scope->getScopeId()), + ]); + $insertQuery->executeStatement(); + } + + public function formatOperation(array $operation): array { + $checkIds = json_decode($operation['checks'], true); + $checks = $this->getChecks($checkIds); + + $operation['checks'] = []; + foreach ($checks as $check) { + // Remove internal values + unset($check['id']); + unset($check['hash']); + + $operation['checks'][] = $check; + } + $operation['events'] = json_decode($operation['events'], true) ?? []; + + + return $operation; + } + + /** + * @return IEntity[] + */ + public function getEntitiesList(): array { + $this->dispatcher->dispatchTyped(new RegisterEntitiesEvent($this)); + + return array_values(array_merge($this->getBuildInEntities(), $this->registeredEntities)); + } + + /** + * @return IOperation[] + */ + public function getOperatorList(): array { + $this->dispatcher->dispatchTyped(new RegisterOperationsEvent($this)); + + return array_merge($this->getBuildInOperators(), $this->registeredOperators); + } + + /** + * @return ICheck[] + */ + public function getCheckList(): array { + $this->dispatcher->dispatchTyped(new RegisterChecksEvent($this)); + + return array_merge($this->getBuildInChecks(), $this->registeredChecks); + } + + public function registerEntity(IEntity $entity): void { + $this->registeredEntities[get_class($entity)] = $entity; + } + + public function registerOperation(IOperation $operator): void { + $this->registeredOperators[get_class($operator)] = $operator; + } + + public function registerCheck(ICheck $check): void { + $this->registeredChecks[get_class($check)] = $check; + } + + /** + * @return IEntity[] + */ + protected function getBuildInEntities(): array { + try { + return [ + File::class => $this->container->query(File::class), + ]; + } catch (QueryException $e) { + $this->logger->error($e->getMessage(), ['exception' => $e]); + return []; + } + } + + /** + * @return IOperation[] + */ + protected function getBuildInOperators(): array { + try { + return [ + // None yet + ]; + } catch (QueryException $e) { + $this->logger->error($e->getMessage(), ['exception' => $e]); + return []; + } + } + + /** + * @return ICheck[] + */ + protected function getBuildInChecks(): array { + try { + return [ + $this->container->query(FileMimeType::class), + $this->container->query(FileName::class), + $this->container->query(FileSize::class), + $this->container->query(FileSystemTags::class), + $this->container->query(RequestRemoteAddress::class), + $this->container->query(RequestTime::class), + $this->container->query(RequestURL::class), + $this->container->query(RequestUserAgent::class), + $this->container->query(UserGroupMembership::class), + ]; + } catch (QueryException $e) { + $this->logger->error($e->getMessage(), ['exception' => $e]); + return []; + } + } + + public function isUserScopeEnabled(): bool { + return $this->config->getAppValue(Application::APP_ID, 'user_scope_disabled', 'no') === 'no'; + } } diff --git a/apps/workflowengine/lib/Migration/PopulateNewlyIntroducedDatabaseFields.php b/apps/workflowengine/lib/Migration/PopulateNewlyIntroducedDatabaseFields.php new file mode 100644 index 00000000000..633d946cd7e --- /dev/null +++ b/apps/workflowengine/lib/Migration/PopulateNewlyIntroducedDatabaseFields.php @@ -0,0 +1,59 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\WorkflowEngine\Migration; + +use OCP\DB\IResult; +use OCP\IDBConnection; +use OCP\Migration\IOutput; +use OCP\Migration\IRepairStep; +use OCP\WorkflowEngine\IManager; + +class PopulateNewlyIntroducedDatabaseFields implements IRepairStep { + + public function __construct( + private IDBConnection $dbc, + ) { + } + + public function getName() { + return 'Populating added database structures for workflows'; + } + + public function run(IOutput $output) { + $result = $this->getIdsWithoutScope(); + + $this->populateScopeTable($result); + + $result->closeCursor(); + } + + protected function populateScopeTable(IResult $ids): void { + $qb = $this->dbc->getQueryBuilder(); + + $insertQuery = $qb->insert('flow_operations_scope'); + while (($id = $ids->fetchOne()) !== false) { + $insertQuery->values(['operation_id' => $qb->createNamedParameter($id), 'type' => IManager::SCOPE_ADMIN]); + $insertQuery->executeStatement(); + } + } + + protected function getIdsWithoutScope(): IResult { + $qb = $this->dbc->getQueryBuilder(); + $selectQuery = $qb->select('o.id') + ->from('flow_operations', 'o') + ->leftJoin('o', 'flow_operations_scope', 's', $qb->expr()->eq('o.id', 's.operation_id')) + ->where($qb->expr()->isNull('s.operation_id')); + // The left join operation is not necessary, usually, but it's a safe-guard + // in case the repair step is executed multiple times for whatever reason. + + /** @var IResult $result */ + $result = $selectQuery->executeQuery(); + return $result; + } +} diff --git a/apps/workflowengine/lib/Migration/Version2000Date20190808074233.php b/apps/workflowengine/lib/Migration/Version2000Date20190808074233.php new file mode 100644 index 00000000000..93f423cada7 --- /dev/null +++ b/apps/workflowengine/lib/Migration/Version2000Date20190808074233.php @@ -0,0 +1,134 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\WorkflowEngine\Migration; + +use Closure; +use Doctrine\DBAL\Schema\Table; +use OCA\WorkflowEngine\Entity\File; +use OCP\DB\ISchemaWrapper; +use OCP\DB\Types; +use OCP\Migration\IOutput; +use OCP\Migration\SimpleMigrationStep; + +class Version2000Date20190808074233 extends SimpleMigrationStep { + + /** + * @param IOutput $output + * @param Closure $schemaClosure The `\Closure` returns a `ISchemaWrapper` + * @param array $options + * @return null|ISchemaWrapper + */ + public function changeSchema(IOutput $output, Closure $schemaClosure, array $options) { + /** @var ISchemaWrapper $schema */ + $schema = $schemaClosure(); + + if (!$schema->hasTable('flow_checks')) { + $table = $schema->createTable('flow_checks'); + $table->addColumn('id', Types::INTEGER, [ + 'autoincrement' => true, + 'notnull' => true, + 'length' => 4, + ]); + $table->addColumn('class', Types::STRING, [ + 'notnull' => true, + 'length' => 256, + 'default' => '', + ]); + $table->addColumn('operator', Types::STRING, [ + 'notnull' => true, + 'length' => 16, + 'default' => '', + ]); + $table->addColumn('value', Types::TEXT, [ + 'notnull' => false, + ]); + $table->addColumn('hash', Types::STRING, [ + 'notnull' => true, + 'length' => 32, + 'default' => '', + ]); + $table->setPrimaryKey(['id']); + $table->addUniqueIndex(['hash'], 'flow_unique_hash'); + } + + if (!$schema->hasTable('flow_operations')) { + $table = $schema->createTable('flow_operations'); + $table->addColumn('id', Types::INTEGER, [ + 'autoincrement' => true, + 'notnull' => true, + 'length' => 4, + ]); + $table->addColumn('class', Types::STRING, [ + 'notnull' => true, + 'length' => 256, + 'default' => '', + ]); + $table->addColumn('name', Types::STRING, [ + 'notnull' => false, + 'length' => 256, + 'default' => '', + ]); + $table->addColumn('checks', Types::TEXT, [ + 'notnull' => false, + ]); + $table->addColumn('operation', Types::TEXT, [ + 'notnull' => false, + ]); + $this->ensureEntityColumns($table); + $table->setPrimaryKey(['id']); + } else { + $table = $schema->getTable('flow_operations'); + $this->ensureEntityColumns($table); + } + + if (!$schema->hasTable('flow_operations_scope')) { + $table = $schema->createTable('flow_operations_scope'); + $table->addColumn('id', Types::BIGINT, [ + 'autoincrement' => true, + 'notnull' => true, + 'length' => 4, + ]); + $table->addColumn('operation_id', Types::INTEGER, [ + 'notnull' => true, + 'length' => 4, + 'default' => 0, + ]); + $table->addColumn('type', Types::INTEGER, [ + 'notnull' => true, + 'length' => 4, + 'default' => 0, + ]); + $table->addColumn('value', Types::STRING, [ + 'notnull' => false, + 'length' => 64, + 'default' => '', + ]); + $table->setPrimaryKey(['id']); + $table->addUniqueIndex(['operation_id', 'type', 'value'], 'flow_unique_scope'); + } + + return $schema; + } + + protected function ensureEntityColumns(Table $table) { + if (!$table->hasColumn('entity')) { + $table->addColumn('entity', Types::STRING, [ + 'notnull' => true, + 'length' => 256, + 'default' => File::class, + ]); + } + if (!$table->hasColumn('events')) { + $table->addColumn('events', Types::TEXT, [ + 'notnull' => true, + 'default' => '[]', + ]); + } + } +} diff --git a/apps/workflowengine/lib/Migration/Version2200Date20210805101925.php b/apps/workflowengine/lib/Migration/Version2200Date20210805101925.php new file mode 100644 index 00000000000..841277acfce --- /dev/null +++ b/apps/workflowengine/lib/Migration/Version2200Date20210805101925.php @@ -0,0 +1,37 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\WorkflowEngine\Migration; + +use Closure; +use OCP\DB\ISchemaWrapper; +use OCP\Migration\IOutput; +use OCP\Migration\SimpleMigrationStep; + +class Version2200Date20210805101925 extends SimpleMigrationStep { + + /** + * @param IOutput $output + * @param Closure $schemaClosure The `\Closure` returns a `ISchemaWrapper` + * @param array $options + * @return null|ISchemaWrapper + */ + public function changeSchema(IOutput $output, Closure $schemaClosure, array $options) { + /** @var ISchemaWrapper $schema */ + $schema = $schemaClosure(); + + if ($schema->hasTable('flow_operations')) { + $table = $schema->getTable('flow_operations'); + $table->changeColumn('name', [ + 'notnull' => false, + ]); + } + + return $schema; + } +} diff --git a/apps/workflowengine/lib/Service/Logger.php b/apps/workflowengine/lib/Service/Logger.php new file mode 100644 index 00000000000..494240bc403 --- /dev/null +++ b/apps/workflowengine/lib/Service/Logger.php @@ -0,0 +1,152 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\WorkflowEngine\Service; + +use OCA\WorkflowEngine\AppInfo\Application; +use OCA\WorkflowEngine\Helper\LogContext; +use OCP\IConfig; +use OCP\ILogger; +use OCP\Log\IDataLogger; +use OCP\Log\ILogFactory; +use Psr\Log\LoggerInterface; + +class Logger { + protected ?LoggerInterface $flowLogger = null; + + public function __construct( + protected LoggerInterface $generalLogger, + private IConfig $config, + private ILogFactory $logFactory, + ) { + $this->initLogger(); + } + + protected function initLogger(): void { + $default = $this->config->getSystemValue('datadirectory', \OC::$SERVERROOT . '/data') . '/flow.log'; + $logFile = trim((string)$this->config->getAppValue(Application::APP_ID, 'logfile', $default)); + if ($logFile !== '') { + $this->flowLogger = $this->logFactory->getCustomPsrLogger($logFile); + } + } + + public function logFlowRequests(LogContext $logContext) { + $message = 'Flow activation: rules were requested for operation {op}'; + $context = ['op' => $logContext->getDetails()['operation']['name'], 'level' => ILogger::DEBUG]; + + $logContext->setDescription('Flow activation: rules were requested'); + + $this->log($message, $context, $logContext); + } + + public function logScopeExpansion(LogContext $logContext) { + $message = 'Flow rule of a different user is legit for operation {op}'; + $context = ['op' => $logContext->getDetails()['operation']['name']]; + + $logContext->setDescription('Flow rule of a different user is legit'); + + $this->log($message, $context, $logContext); + } + + public function logPassedCheck(LogContext $logContext) { + $message = 'Flow rule qualified to run {op}, config: {config}'; + $context = [ + 'op' => $logContext->getDetails()['operation']['name'], + 'config' => $logContext->getDetails()['configuration'], + 'level' => ILogger::DEBUG, + ]; + + $logContext->setDescription('Flow rule qualified to run'); + + $this->log($message, $context, $logContext); + } + + public function logRunSingle(LogContext $logContext) { + $message = 'Last qualified flow configuration is going to run {op}'; + $context = [ + 'op' => $logContext->getDetails()['operation']['name'], + ]; + + $logContext->setDescription('Last qualified flow configuration is going to run'); + + $this->log($message, $context, $logContext); + } + + public function logRunAll(LogContext $logContext) { + $message = 'All qualified flow configurations are going to run {op}'; + $context = [ + 'op' => $logContext->getDetails()['operation']['name'], + ]; + + $logContext->setDescription('All qualified flow configurations are going to run'); + + $this->log($message, $context, $logContext); + } + + public function logRunNone(LogContext $logContext) { + $message = 'No flow configurations is going to run {op}'; + $context = [ + 'op' => $logContext->getDetails()['operation']['name'], + 'level' => ILogger::DEBUG, + ]; + + $logContext->setDescription('No flow configurations is going to run'); + + $this->log($message, $context, $logContext); + } + + public function logEventInit(LogContext $logContext) { + $message = 'Flow activated by event {ev}'; + + $context = [ + 'ev' => $logContext->getDetails()['eventName'], + 'level' => ILogger::DEBUG, + ]; + + $logContext->setDescription('Flow activated by event'); + + $this->log($message, $context, $logContext); + } + + public function logEventDone(LogContext $logContext) { + $message = 'Flow handling done for event {ev}'; + + $context = [ + 'ev' => $logContext->getDetails()['eventName'], + ]; + + $logContext->setDescription('Flow handling for event done'); + + $this->log($message, $context, $logContext); + } + + protected function log( + string $message, + array $context, + LogContext $logContext, + ): void { + if (!isset($context['app'])) { + $context['app'] = Application::APP_ID; + } + if (!isset($context['level'])) { + $context['level'] = ILogger::INFO; + } + $this->generalLogger->log($context['level'], $message, $context); + + if (!$this->flowLogger instanceof IDataLogger) { + return; + } + + $details = $logContext->getDetails(); + $this->flowLogger->logData( + $details['message'], + $details, + ['app' => Application::APP_ID, 'level' => $context['level']] + ); + } +} diff --git a/apps/workflowengine/lib/Service/RuleMatcher.php b/apps/workflowengine/lib/Service/RuleMatcher.php new file mode 100644 index 00000000000..c95387e14ee --- /dev/null +++ b/apps/workflowengine/lib/Service/RuleMatcher.php @@ -0,0 +1,211 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\WorkflowEngine\Service; + +use OCA\WorkflowEngine\Helper\LogContext; +use OCA\WorkflowEngine\Helper\ScopeContext; +use OCA\WorkflowEngine\Manager; +use OCP\AppFramework\QueryException; +use OCP\Files\Storage\IStorage; +use OCP\IL10N; +use OCP\IServerContainer; +use OCP\IUserSession; +use OCP\WorkflowEngine\ICheck; +use OCP\WorkflowEngine\IEntity; +use OCP\WorkflowEngine\IEntityCheck; +use OCP\WorkflowEngine\IFileCheck; +use OCP\WorkflowEngine\IManager; +use OCP\WorkflowEngine\IOperation; +use OCP\WorkflowEngine\IRuleMatcher; +use RuntimeException; + +class RuleMatcher implements IRuleMatcher { + + /** @var array */ + protected $contexts; + /** @var array */ + protected $fileInfo = []; + /** @var IOperation */ + protected $operation; + /** @var IEntity */ + protected $entity; + /** @var string */ + protected $eventName; + + public function __construct( + protected IUserSession $session, + protected IServerContainer $container, + protected IL10N $l, + protected Manager $manager, + protected Logger $logger, + ) { + } + + public function setFileInfo(IStorage $storage, string $path, bool $isDir = false): void { + $this->fileInfo['storage'] = $storage; + $this->fileInfo['path'] = $path; + $this->fileInfo['isDir'] = $isDir; + } + + public function setEntitySubject(IEntity $entity, $subject): void { + $this->contexts[get_class($entity)] = [$entity, $subject]; + } + + public function setOperation(IOperation $operation): void { + if ($this->operation !== null) { + throw new RuntimeException('This method must not be called more than once'); + } + $this->operation = $operation; + } + + public function setEntity(IEntity $entity): void { + if ($this->entity !== null) { + throw new RuntimeException('This method must not be called more than once'); + } + $this->entity = $entity; + } + + public function setEventName(string $eventName): void { + if ($this->eventName !== null) { + throw new RuntimeException('This method must not be called more than once'); + } + $this->eventName = $eventName; + } + + public function getEntity(): IEntity { + if ($this->entity === null) { + throw new \LogicException('Entity was not set yet'); + } + return $this->entity; + } + + public function getFlows(bool $returnFirstMatchingOperationOnly = true): array { + if (!$this->operation) { + throw new RuntimeException('Operation is not set'); + } + return $this->getMatchingOperations(get_class($this->operation), $returnFirstMatchingOperationOnly); + } + + public function getMatchingOperations(string $class, bool $returnFirstMatchingOperationOnly = true): array { + $scopes[] = new ScopeContext(IManager::SCOPE_ADMIN); + $user = $this->session->getUser(); + if ($user !== null && $this->manager->isUserScopeEnabled()) { + $scopes[] = new ScopeContext(IManager::SCOPE_USER, $user->getUID()); + } + + $ctx = new LogContext(); + $ctx + ->setScopes($scopes) + ->setEntity($this->entity) + ->setOperation($this->operation); + $this->logger->logFlowRequests($ctx); + + $operations = []; + foreach ($scopes as $scope) { + $operations = array_merge($operations, $this->manager->getOperations($class, $scope)); + } + + if ($this->entity instanceof IEntity) { + /** @var ScopeContext[] $additionalScopes */ + $additionalScopes = $this->manager->getAllConfiguredScopesForOperation($class); + foreach ($additionalScopes as $hash => $scopeCandidate) { + if ($scopeCandidate->getScope() !== IManager::SCOPE_USER || in_array($scopeCandidate, $scopes)) { + continue; + } + if ($this->entity->isLegitimatedForUserId($scopeCandidate->getScopeId())) { + $ctx = new LogContext(); + $ctx + ->setScopes([$scopeCandidate]) + ->setEntity($this->entity) + ->setOperation($this->operation); + $this->logger->logScopeExpansion($ctx); + $operations = array_merge($operations, $this->manager->getOperations($class, $scopeCandidate)); + } + } + } + + $matches = []; + foreach ($operations as $operation) { + $configuredEvents = json_decode($operation['events'], true); + if ($this->eventName !== null && !in_array($this->eventName, $configuredEvents)) { + continue; + } + + $checkIds = json_decode($operation['checks'], true); + $checks = $this->manager->getChecks($checkIds); + + foreach ($checks as $check) { + if (!$this->check($check)) { + // Check did not match, continue with the next operation + continue 2; + } + } + + $ctx = new LogContext(); + $ctx + ->setEntity($this->entity) + ->setOperation($this->operation) + ->setConfiguration($operation); + $this->logger->logPassedCheck($ctx); + + if ($returnFirstMatchingOperationOnly) { + $ctx = new LogContext(); + $ctx + ->setEntity($this->entity) + ->setOperation($this->operation) + ->setConfiguration($operation); + $this->logger->logRunSingle($ctx); + return $operation; + } + $matches[] = $operation; + } + + $ctx = new LogContext(); + $ctx + ->setEntity($this->entity) + ->setOperation($this->operation); + if (!empty($matches)) { + $ctx->setConfiguration($matches); + $this->logger->logRunAll($ctx); + } else { + $this->logger->logRunNone($ctx); + } + + return $matches; + } + + /** + * @param array $check + * @return bool + */ + public 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 IFileCheck) { + if (empty($this->fileInfo)) { + throw new RuntimeException('Must set file info before running the check'); + } + $checkInstance->setFileInfo($this->fileInfo['storage'], $this->fileInfo['path'], $this->fileInfo['isDir']); + } elseif ($checkInstance instanceof IEntityCheck) { + foreach ($this->contexts as $entityInfo) { + [$entity, $subject] = $entityInfo; + $checkInstance->setEntitySubject($entity, $subject); + } + } elseif (!$checkInstance instanceof ICheck) { + // Check is invalid + throw new \UnexpectedValueException($this->l->t('Check %s is invalid or does not exist', $check['class'])); + } + return $checkInstance->executeCheck($check['operator'], $check['value']); + } +} diff --git a/apps/workflowengine/lib/Settings/ASettings.php b/apps/workflowengine/lib/Settings/ASettings.php new file mode 100644 index 00000000000..23e958755de --- /dev/null +++ b/apps/workflowengine/lib/Settings/ASettings.php @@ -0,0 +1,155 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\WorkflowEngine\Settings; + +use OCA\WorkflowEngine\AppInfo\Application; +use OCA\WorkflowEngine\Manager; +use OCP\AppFramework\Http\TemplateResponse; +use OCP\AppFramework\Services\IInitialState; +use OCP\EventDispatcher\IEventDispatcher; +use OCP\IConfig; +use OCP\IL10N; +use OCP\IURLGenerator; +use OCP\Settings\ISettings; +use OCP\WorkflowEngine\Events\LoadSettingsScriptsEvent; +use OCP\WorkflowEngine\ICheck; +use OCP\WorkflowEngine\IComplexOperation; +use OCP\WorkflowEngine\IEntity; +use OCP\WorkflowEngine\IEntityEvent; +use OCP\WorkflowEngine\IOperation; +use OCP\WorkflowEngine\ISpecificOperation; + +abstract class ASettings implements ISettings { + public function __construct( + private string $appName, + private IL10N $l10n, + private IEventDispatcher $eventDispatcher, + protected Manager $manager, + private IInitialState $initialStateService, + private IConfig $config, + private IURLGenerator $urlGenerator, + ) { + } + + abstract public function getScope(): int; + + /** + * @return TemplateResponse + */ + public function getForm(): TemplateResponse { + // @deprecated in 20.0.0: retire this one in favor of the typed event + $this->eventDispatcher->dispatch( + 'OCP\WorkflowEngine::loadAdditionalSettingScripts', + new LoadSettingsScriptsEvent() + ); + $this->eventDispatcher->dispatchTyped(new LoadSettingsScriptsEvent()); + + $entities = $this->manager->getEntitiesList(); + $this->initialStateService->provideInitialState( + 'entities', + $this->entitiesToArray($entities) + ); + + $operators = $this->manager->getOperatorList(); + $this->initialStateService->provideInitialState( + 'operators', + $this->operatorsToArray($operators) + ); + + $checks = $this->manager->getCheckList(); + $this->initialStateService->provideInitialState( + 'checks', + $this->checksToArray($checks) + ); + + $this->initialStateService->provideInitialState( + 'scope', + $this->getScope() + ); + + $this->initialStateService->provideInitialState( + 'appstoreenabled', + $this->config->getSystemValueBool('appstoreenabled', true) + ); + + $this->initialStateService->provideInitialState( + 'doc-url', + $this->urlGenerator->linkToDocs('admin-workflowengine') + ); + + return new TemplateResponse(Application::APP_ID, 'settings', [], 'blank'); + } + + /** + * @return string the section ID, e.g. 'sharing' + */ + public function getSection(): ?string { + return 'workflow'; + } + + /** + * @return int whether the form should be rather on the top or bottom of + * the admin section. The forms are arranged in ascending order of the + * priority values. It is required to return a value between 0 and 100. + * + * E.g.: 70 + */ + public function getPriority(): int { + return 0; + } + + private function entitiesToArray(array $entities) { + return array_map(function (IEntity $entity) { + $events = array_map(function (IEntityEvent $entityEvent) { + return [ + 'eventName' => $entityEvent->getEventName(), + 'displayName' => $entityEvent->getDisplayName() + ]; + }, $entity->getEvents()); + + return [ + 'id' => get_class($entity), + 'icon' => $entity->getIcon(), + 'name' => $entity->getName(), + 'events' => $events, + ]; + }, $entities); + } + + private function operatorsToArray(array $operators) { + $operators = array_filter($operators, function (IOperation $operator) { + return $operator->isAvailableForScope($this->getScope()); + }); + + return array_map(function (IOperation $operator) { + return [ + 'id' => get_class($operator), + 'icon' => $operator->getIcon(), + 'name' => $operator->getDisplayName(), + 'description' => $operator->getDescription(), + 'fixedEntity' => $operator instanceof ISpecificOperation ? $operator->getEntityId() : '', + 'isComplex' => $operator instanceof IComplexOperation, + 'triggerHint' => $operator instanceof IComplexOperation ? $operator->getTriggerHint() : '', + ]; + }, $operators); + } + + private function checksToArray(array $checks) { + $checks = array_filter($checks, function (ICheck $check) { + return $check->isAvailableForScope($this->getScope()); + }); + + return array_map(function (ICheck $check) { + return [ + 'id' => get_class($check), + 'supportedEntities' => $check->supportedEntities(), + ]; + }, $checks); + } +} diff --git a/apps/workflowengine/lib/Settings/Admin.php b/apps/workflowengine/lib/Settings/Admin.php new file mode 100644 index 00000000000..c2018593c66 --- /dev/null +++ b/apps/workflowengine/lib/Settings/Admin.php @@ -0,0 +1,17 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\WorkflowEngine\Settings; + +use OCP\WorkflowEngine\IManager; + +class Admin extends ASettings { + public function getScope(): int { + return IManager::SCOPE_ADMIN; + } +} diff --git a/apps/workflowengine/lib/Settings/Personal.php b/apps/workflowengine/lib/Settings/Personal.php new file mode 100644 index 00000000000..0a70f8dbe75 --- /dev/null +++ b/apps/workflowengine/lib/Settings/Personal.php @@ -0,0 +1,21 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\WorkflowEngine\Settings; + +use OCP\WorkflowEngine\IManager; + +class Personal extends ASettings { + public function getScope(): int { + return IManager::SCOPE_USER; + } + + public function getSection(): ?string { + return $this->manager->isUserScopeEnabled() ? 'workflow' : null; + } +} diff --git a/apps/workflowengine/lib/Settings/Section.php b/apps/workflowengine/lib/Settings/Section.php index b46f9a4a35f..aa790c9ddcc 100644 --- a/apps/workflowengine/lib/Settings/Section.php +++ b/apps/workflowengine/lib/Settings/Section.php @@ -1,45 +1,25 @@ <?php + /** - * @copyright Copyright (c) 2016 Lukas Reschke <lukas@statuscode.ch> - * - * @author Lukas Reschke <lukas@statuscode.ch> - * - * @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/>. - * + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ - namespace OCA\WorkflowEngine\Settings; +use OCA\WorkflowEngine\AppInfo\Application; use OCP\IL10N; use OCP\IURLGenerator; use OCP\Settings\IIconSection; class Section implements IIconSection { - /** @var IL10N */ - private $l; - /** @var IURLGenerator */ - private $url; - /** * @param IURLGenerator $url * @param IL10N $l */ - public function __construct(IURLGenerator $url, IL10N $l) { - $this->url = $url; - $this->l = $l; + public function __construct( + private IURLGenerator $url, + private IL10N $l, + ) { } /** @@ -53,7 +33,7 @@ class Section implements IIconSection { * {@inheritdoc} */ public function getName() { - return $this->l->t('Workflow'); + return $this->l->t('Flow'); } /** @@ -67,6 +47,6 @@ class Section implements IIconSection { * {@inheritdoc} */ public function getIcon() { - return $this->url->imagePath('core', 'actions/tag.svg'); + return $this->url->imagePath(Application::APP_ID, 'app-dark.svg'); } } |