diff --git a/appinfo/info.xml b/appinfo/info.xml index d5f69dab7..886eb0a93 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -11,7 +11,7 @@ - **đŸ”’ Data under your control!** Unlike in Google Forms, Typeform, Doodle and others, the survey info and responses are kept private on your instance. - **đŸ™‹ Get involved!** We have lots of stuff planned like more question types, collaboration on forms, [and much more](https://github.com/nextcloud/forms/milestones)! ]]> - 2.5.0 + 3.0.0-alpha.0 agpl Affan Hussain diff --git a/appinfo/routes.php b/appinfo/routes.php index c8662fe62..5c89533d7 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -25,15 +25,22 @@ return [ 'routes' => [ + // Public Share Link + [ + 'name' => 'page#public_link_view', + 'url' => '/s/{hash}', + 'verb' => 'GET' + + ], // Internal views [ 'name' => 'page#views', 'url' => '/{hash}/{view}', 'verb' => 'GET' ], - // Share-Link & public submit + // Internal Form Link [ - 'name' => 'page#goto_form', + 'name' => 'page#internal_link_view', 'url' => '/{hash}', 'verb' => 'GET' ], @@ -95,6 +102,14 @@ 'apiVersion' => 'v1(\.1)?' ] ], + [ + 'name' => 'api#getPartialForm', + 'url' => '/api/{apiVersion}/partial_form/{hash}', + 'verb' => 'GET', + 'requirements' => [ + 'apiVersion' => 'v2' + ] + ], [ 'name' => 'api#getSharedForms', 'url' => '/api/{apiVersion}/shared_forms', @@ -164,6 +179,24 @@ ] ], + // Shares + [ + 'name' => 'shareApi#newShare', + 'url' => '/api/{apiVersion}/share', + 'verb' => 'POST', + 'requirements' => [ + 'apiVersion' => 'v2' + ] + ], + [ + 'name' => 'shareApi#deleteShare', + 'url' => '/api/{apiVersion}/share/{id}', + 'verb' => 'DELETE', + 'requirements' => [ + 'apiVersion' => 'v2' + ] + ], + // Submissions [ 'name' => 'api#getSubmissions', diff --git a/css/icons.scss b/css/icons.scss index b68d58994..b736c26ed 100644 --- a/css/icons.scss +++ b/css/icons.scss @@ -38,8 +38,8 @@ @include icon-color('checkmark', 'actions', $color-success, 1, true); } -.icon-clippy-primary { - @include icon-color('clippy', 'actions', $color-primary-text, 1, true); +.icon-share-primary { + @include icon-color('share', 'actions', $color-primary-text, 1, true); } .icon-comment-yes { diff --git a/lib/Constants.php b/lib/Constants.php index 39ddb65a2..6d0763091 100644 --- a/lib/Constants.php +++ b/lib/Constants.php @@ -23,6 +23,8 @@ namespace OCA\Forms; +use OCP\Share\IShare; + class Constants { /** * Maximum String lengths, the database is set to store. @@ -82,4 +84,28 @@ class Constants { self::ANSWER_TYPE_DATETIME => 'Y-m-d H:i', self::ANSWER_TYPE_TIME => 'H:i' ]; + + /** + * !! Keep in sync with src/mixins/ShareTypes.js !! + */ + public const SHARE_TYPES_USED = [ + IShare::TYPE_USER, + IShare::TYPE_GROUP, + IShare::TYPE_LINK + ]; + + /** + * !! Keep in sync with src/mixins/PermissionTypes.js !! + * Permission values equal the route names, thus making it easy on frontend to evaluate. + */ + // Define Form Permissions + public const PERMISSION_EDIT = 'edit'; + public const PERMISSION_RESULTS = 'results'; + public const PERMISSION_SUBMIT = 'submit'; + + public const PERMISSION_ALL = [ + self::PERMISSION_EDIT, + self::PERMISSION_RESULTS, + self::PERMISSION_SUBMIT + ]; } diff --git a/lib/Controller/ApiController.php b/lib/Controller/ApiController.php index 307887075..5bdcda3da 100644 --- a/lib/Controller/ApiController.php +++ b/lib/Controller/ApiController.php @@ -27,8 +27,6 @@ namespace OCA\Forms\Controller; -use Exception; - use OCA\Forms\Activity\ActivityManager; use OCA\Forms\Constants; use OCA\Forms\Db\Answer; @@ -39,6 +37,7 @@ use OCA\Forms\Db\OptionMapper; use OCA\Forms\Db\Question; use OCA\Forms\Db\QuestionMapper; +use OCA\Forms\Db\ShareMapper; use OCA\Forms\Db\Submission; use OCA\Forms\Db\SubmissionMapper; use OCA\Forms\Service\FormsService; @@ -79,6 +78,9 @@ class ApiController extends OCSController { /** @var QuestionMapper */ private $questionMapper; + /** @var ShareMapper */ + private $shareMapper; + /** @var SubmissionMapper */ private $submissionMapper; @@ -109,6 +111,7 @@ public function __construct(string $appName, FormMapper $formMapper, OptionMapper $optionMapper, QuestionMapper $questionMapper, + ShareMapper $shareMapper, SubmissionMapper $submissionMapper, FormsService $formsService, SubmissionService $submissionService, @@ -125,6 +128,7 @@ public function __construct(string $appName, $this->formMapper = $formMapper; $this->optionMapper = $optionMapper; $this->questionMapper = $questionMapper; + $this->shareMapper = $shareMapper; $this->submissionMapper = $submissionMapper; $this->formsService = $formsService; $this->submissionService = $submissionService; @@ -149,13 +153,7 @@ public function getForms(): DataResponse { $result = []; foreach ($forms as $form) { - $result[] = [ - 'id' => $form->getId(), - 'hash' => $form->getHash(), - 'title' => $form->getTitle(), - 'expires' => $form->getExpires(), - 'partial' => true - ]; + $result[] = $this->formsService->getPartialFormArray($form->getId()); } return new DataResponse($result); @@ -173,26 +171,41 @@ public function getSharedForms(): DataResponse { $result = []; foreach ($forms as $form) { - // Don't add if user is owner, user has no access, form has expired, form is link-shared - if ($form->getOwnerId() === $this->currentUser->getUID() - || !$this->formsService->hasUserAccess($form->getId()) - || $this->formsService->hasFormExpired($form->getId()) - || $form->getAccess()['type'] === 'public') { + // Check if the form should be shown on sidebar + if (!$this->formsService->isSharedFormShown($form->getId())) { continue; } - - $result[] = [ - 'id' => $form->getId(), - 'hash' => $form->getHash(), - 'title' => $form->getTitle(), - 'expires' => $form->getExpires(), - 'partial' => true - ]; + $result[] = $this->formsService->getPartialFormArray($form->getId()); } return new DataResponse($result); } + /** + * @NoAdminRequired + * + * Get a partial form by its hash. Implicitely checks, if the user has access. + * + * @param string $hash The form hash + * @return DataResponse + * @throws OCSBadRequestException if forbidden or not found + */ + public function getPartialForm(string $hash): DataResponse { + try { + $form = $this->formMapper->findByHash($hash); + } catch (IMapperException $e) { + $this->logger->debug('Could not find form'); + throw new OCSBadRequestException(); + } + + if (!$this->formsService->hasUserAccess($form->getId())) { + $this->logger->debug('User has no permissions to get this form'); + throw new OCSForbiddenException(); + } + + return new DataResponse($this->formsService->getPartialFormArray($form->getId())); + } + /** * @NoAdminRequired * @@ -246,9 +259,8 @@ public function newForm(): DataResponse { $form->setTitle(''); $form->setDescription(''); $form->setAccess([ - 'type' => 'public', - 'users' => [], - 'groups' => [] + 'permitAllUsers' => false, + 'showToAllUsers' => false, ]); $form->setSubmitOnce(true); @@ -257,6 +269,7 @@ public function newForm(): DataResponse { // Return like getForm(), just without loading Questions (as there are none). $result = $form->read(); $result['questions'] = []; + $result['shares'] = []; return new DataResponse($result); } @@ -371,28 +384,6 @@ public function updateForm(int $id, array $keyValuePairs): DataResponse { throw new OCSForbiddenException(); } - // Handle access-changes - if (array_key_exists('access', $keyValuePairs)) { - // Make sure we only store id of shares - try { - $keyValuePairs['access']['users'] = array_map(function (array $user): string { - return $user['shareWith']; - }, $keyValuePairs['access']['users']); - $keyValuePairs['access']['groups'] = array_map(function (array $group): string { - return $group['shareWith']; - }, $keyValuePairs['access']['groups']); - } catch (Exception $e) { - $this->logger->debug('Malformed access'); - throw new OCSBadRequestException('Malformed access'); - } - - // For selected sharing, notify users (creates Activity) - if ($keyValuePairs['access']['type'] === 'selected') { - $oldAccess = $form->getAccess(); - $this->formsService->notifyNewShares($form, $oldAccess, $keyValuePairs['access']); - } - } - // Create FormEntity with given Params & Id. $form = Form::fromParams($keyValuePairs); $form->setId($id); @@ -947,14 +938,16 @@ public function getSubmissions(string $hash): DataResponse { * * @param int $formId the form id * @param array $answers [question_id => arrayOfString] + * @param string $shareHash public share-hash -> Necessary to submit on public link-shares. * @return DataResponse * @throws OCSBadRequestException * @throws OCSForbiddenException */ - public function insertSubmission(int $formId, array $answers): DataResponse { - $this->logger->debug('Inserting submission: formId: {formId}, answers: {answers}', [ + public function insertSubmission(int $formId, array $answers, string $shareHash = ''): DataResponse { + $this->logger->debug('Inserting submission: formId: {formId}, answers: {answers}, shareHash: {shareHash}', [ 'formId' => $formId, 'answers' => $answers, + 'shareHash' => $shareHash, ]); try { @@ -965,9 +958,30 @@ public function insertSubmission(int $formId, array $answers): DataResponse { throw new OCSBadRequestException(); } - // Does the user have access to the form - if (!$this->formsService->hasUserAccess($form->getId())) { - throw new OCSForbiddenException('Not allowed to access this form'); + // Does the user have access to the form (Either by logged in user, or by providing public share-hash.) + try { + $isPublicShare = false; + + // If hash given, find the corresponding share & check if hash corresponds to given formId. + if ($shareHash !== '') { + // public by legacy Link + if ($form->getAccess()['legacyLink'] && $shareHash === $form->getHash()) { + $isPublicShare = true; + } + + // Public link share + $share = $this->shareMapper->findPublicShareByHash($shareHash); + if ($share->getFormId() === $formId) { + $isPublicShare = true; + } + } + } catch (DoesNotExistException $e) { + // $isPublicShare already false. + } finally { + // Now forbid, if no public share and no direct share. + if (!$isPublicShare && !$this->formsService->hasUserAccess($form->getId())) { + throw new OCSForbiddenException('Not allowed to access this form'); + } } // Not allowed if form has expired. diff --git a/lib/Controller/PageController.php b/lib/Controller/PageController.php index 06c375a57..2bc19f131 100644 --- a/lib/Controller/PageController.php +++ b/lib/Controller/PageController.php @@ -29,6 +29,7 @@ use OCA\Forms\Constants; use OCA\Forms\Db\Form; use OCA\Forms\Db\FormMapper; +use OCA\Forms\Db\ShareMapper; use OCA\Forms\Service\FormsService; use OCP\Accounts\IAccountManager; @@ -59,7 +60,10 @@ class PageController extends Controller { /** @var FormMapper */ private $formMapper; - + + /** @var ShareMapper */ + private $shareMapper; + /** @var FormsService */ private $formsService; @@ -93,6 +97,7 @@ class PageController extends Controller { public function __construct(string $appName, IRequest $request, FormMapper $formMapper, + ShareMapper $shareMapper, FormsService $formsService, IAccountManager $accountManager, IGroupManager $groupManager, @@ -107,6 +112,7 @@ public function __construct(string $appName, $this->appName = $appName; $this->formMapper = $formMapper; + $this->shareMapper = $shareMapper; $this->formsService = $formsService; $this->accountManager = $accountManager; @@ -149,29 +155,61 @@ public function views(): TemplateResponse { * @NoCSRFRequired * @PublicPage * @param string $hash - * @return RedirectResponse|TemplateResponse Redirect for logged-in users, public template otherwise. + * @return RedirectResponse|TemplateResponse Redirect to login or internal view. */ - public function gotoForm(string $hash): Response { - // Inject style on all templates - Util::addStyle($this->appName, 'forms'); + public function internalLinkView(string $hash): Response { + $internalView = $this->urlGenerator->linkToRoute('forms.page.views', ['hash' => $hash, 'view' => 'submit']); + + if ($this->userSession->isLoggedIn()) { + // Redirect to internal Submit View + return new RedirectResponse($internalView); + } + // For legacy-support, show public template try { $form = $this->formMapper->findByHash($hash); } catch (DoesNotExistException $e) { return $this->provideTemplate(self::TEMPLATE_NOTFOUND); } + if (isset($form->getAccess()['legacyLink'])) { + // Inject style on all templates + Util::addStyle($this->appName, 'forms'); - // If not link-shared, redirect to internal route - if ($form->getAccess()['type'] !== 'public') { - $internalLink = $this->urlGenerator->linkToRoute('forms.page.views', ['hash' => $hash, 'view' => 'submit']); - - if ($this->userSession->isLoggedIn()) { - // Directly internal view - return new RedirectResponse($internalLink); - } else { - // Internal through login - return new RedirectResponse($this->urlGenerator->linkToRoute('core.login.showLoginForm', ['redirect_url' => $internalLink])); + // Has form expired + if ($this->formsService->hasFormExpired($form->getId())) { + return $this->provideTemplate(self::TEMPLATE_EXPIRED, $form); } + + // Public Template to fill the form + Util::addScript($this->appName, 'forms-submit'); + $this->insertHeaderOnIos(); + $this->initialStateService->provideInitialState($this->appName, 'form', $this->formsService->getPublicForm($form->getId())); + $this->initialStateService->provideInitialState($this->appName, 'isLoggedIn', $this->userSession->isLoggedIn()); + $this->initialStateService->provideInitialState($this->appName, 'shareHash', $hash); + $this->initialStateService->provideInitialState($this->appName, 'maxStringLengths', Constants::MAX_STRING_LENGTHS); + return $this->provideTemplate(self::TEMPLATE_MAIN, $form); + } + + // Otherwise Redirect to login (& then internal view) + return new RedirectResponse($this->urlGenerator->linkToRoute('core.login.showLoginForm', ['redirect_url' => $internalView])); + } + + /** + * @NoAdminRequired + * @NoCSRFRequired + * @PublicPage + * @param string $hash Public sharing hash. + * @return TemplateResponse Public template. + */ + public function publicLinkView(string $hash): Response { + // Inject style on all templates + Util::addStyle($this->appName, 'forms'); + + try { + $share = $this->shareMapper->findPublicShareByHash($hash); + $form = $this->formMapper->findById($share->getFormId()); + } catch (DoesNotExistException $e) { + return $this->provideTemplate(self::TEMPLATE_NOTFOUND); } // Has form expired @@ -184,6 +222,7 @@ public function gotoForm(string $hash): Response { $this->insertHeaderOnIos(); $this->initialStateService->provideInitialState($this->appName, 'form', $this->formsService->getPublicForm($form->getId())); $this->initialStateService->provideInitialState($this->appName, 'isLoggedIn', $this->userSession->isLoggedIn()); + $this->initialStateService->provideInitialState($this->appName, 'shareHash', $hash); $this->initialStateService->provideInitialState($this->appName, 'maxStringLengths', Constants::MAX_STRING_LENGTHS); return $this->provideTemplate(self::TEMPLATE_MAIN, $form); } diff --git a/lib/Controller/ShareApiController.php b/lib/Controller/ShareApiController.php new file mode 100644 index 000000000..3dd19431d --- /dev/null +++ b/lib/Controller/ShareApiController.php @@ -0,0 +1,234 @@ + + * + * @author Jonas Rittershofer + * + * @license AGPL-3.0-or-later + * + * 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 . + * + */ + +namespace OCA\Forms\Controller; + +use OCA\Forms\Constants; +use OCA\Forms\Db\Form; +use OCA\Forms\Db\FormMapper; +use OCA\Forms\Db\Share; +use OCA\Forms\Db\ShareMapper; +use OCA\Forms\Service\FormsService; + +use OCP\AppFramework\OCSController; +use OCP\AppFramework\Db\DoesNotExistException; +use OCP\AppFramework\Db\IMapperException; +use OCP\AppFramework\Http\DataResponse; +use OCP\AppFramework\OCS\OCSBadRequestException; +use OCP\AppFramework\OCS\OCSException; +use OCP\AppFramework\OCS\OCSForbiddenException; +use OCP\IGroup; +use OCP\IGroupManager; +use OCP\ILogger; +use OCP\IRequest; +use OCP\IUser; +use OCP\IUserManager; +use OCP\IUserSession; +use OCP\Security\ISecureRandom; +use OCP\Share\IShare; + +class ShareApiController extends OCSController { + protected $appName; + + /** @var FormMapper */ + private $formMapper; + + /** @var ShareMapper */ + private $shareMapper; + + /** @var FormsService */ + private $formsService; + + /** @var IGroupManager */ + private $groupManager; + + /** @var ILogger */ + private $logger; + + /** @var IUserManager */ + private $userManager; + + /** @var IUser */ + private $currentUser; + + /** @var ISecureRandom */ + private $secureRandom; + + public function __construct(string $appName, + FormMapper $formMapper, + ShareMapper $shareMapper, + FormsService $formsService, + IGroupManager $groupManager, + ILogger $logger, + IRequest $request, + IUserManager $userManager, + IUserSession $userSession, + ISecureRandom $secureRandom) { + parent::__construct($appName, $request); + $this->appName = $appName; + $this->formMapper = $formMapper; + $this->shareMapper = $shareMapper; + $this->formsService = $formsService; + $this->groupManager = $groupManager; + $this->logger = $logger; + $this->userManager = $userManager; + $this->secureRandom = $secureRandom; + + $this->currentUser = $userSession->getUser(); + } + + /** + * @NoAdminRequired + * + * Add a new share + * + * @param int $formId The form to share + * @param int $shareType Nextcloud-ShareType + * @param string $shareWith ID of user/group/... to share with. For Empty shareWith and shareType Link, this will be set as RandomID. + * @return DataResponse + * @throws OCSBadRequestException + * @throws OCSForbiddenException + */ + public function newShare(int $formId, int $shareType, string $shareWith = ''): DataResponse { + $this->logger->debug('Adding new share: formId: {formId}, shareType: {shareType}, shareWith: {shareWith}', [ + 'formId' => $formId, + 'shareType' => $shareType, + 'shareWith' => $shareWith, + ]); + + // Only accept usable shareTypes + if (array_search($shareType, Constants::SHARE_TYPES_USED) === false) { + $this->logger->debug('Invalid shareType'); + throw new OCSBadRequestException('Invalid shareType'); + } + + try { + $form = $this->formMapper->findById($formId); + } catch (IMapperException $e) { + $this->logger->debug('Could not find form', ['exception' => $e]); + throw new OCSBadRequestException('Could not find form'); + } + + // Check for permission to share form + if ($form->getOwnerId() !== $this->currentUser->getUID()) { + $this->logger->debug('This form is not owned by the current user'); + throw new OCSForbiddenException(); + } + + // Create public-share hash, if necessary. + if ($shareType === IShare::TYPE_LINK) { + $shareWith = $this->secureRandom->generate( + 24, + ISecureRandom::CHAR_HUMAN_READABLE + ); + } + + // Check for valid shareWith, needs to be done separately per shareType + switch ($shareType) { + case IShare::TYPE_USER: + if (!($this->userManager->get($shareWith) instanceof IUser)) { + $this->logger->debug('Invalid user to share with.'); + throw new OCSBadRequestException('Invalid user to share with.'); + } + break; + + case IShare::TYPE_GROUP: + if (!($this->groupManager->get($shareWith) instanceof IGroup)) { + $this->logger->debug('Invalid group to share with.'); + throw new OCSBadRequestException('Invalid group to share with.'); + } + break; + + case IShare::TYPE_LINK: + // Check if hash already exists. (Unfortunately not possible here by unique index on db.) + try { + // Try loading a share to the hash. + $nonex = $this->shareMapper->findPublicShareByHash($shareWith); + + // If we come here, a share has been found --> The share hash already exists, thus aborting. + $this->logger->debug('Share Hash already exists.'); + throw new OCSException('Share Hash exists. Please retry.'); + } catch (DoesNotExistException $e) { + // Just continue, this is what we expect to happen (share hash not existing yet). + } + break; + + default: + // This passed the check for used shareTypes, but has not been found here. + $this->logger->warning('Unknown, but used shareType: {shareType}. Please file an issue on GitHub.', [ 'shareType' => $shareType ]); + throw new OCSException('Unknown shareType.'); + } + + $share = new Share(); + $share->setFormId($formId); + $share->setShareType($shareType); + $share->setShareWith($shareWith); + + $share = $this->shareMapper->insert($share); + + // Create share-notifications (activity) + $this->formsService->notifyNewShares($form, $share); + + // Append displayName for Frontend + $shareData = $share->read(); + $shareData['displayName'] = $this->formsService->getShareDisplayName($shareData); + + return new DataResponse($shareData); + } + + /** + * @NoAdminRequired + * + * Delete a share + * + * @param int $id of the share to delete + * @return DataResponse + * @throws OCSBadRequestException + * @throws OCSForbiddenException + */ + public function deleteShare(int $id): DataResponse { + $this->logger->debug('Deleting share: {id}', [ + 'id' => $id + ]); + + try { + $share = $this->shareMapper->findById($id); + $form = $this->formMapper->findById($share->getFormId()); + } catch (IMapperException $e) { + $this->logger->debug('Could not find share', ['exception' => $e]); + throw new OCSBadRequestException('Could not find share'); + } + + if ($form->getOwnerId() !== $this->currentUser->getUID()) { + $this->logger->debug('This form is not owned by the current user'); + throw new OCSForbiddenException(); + } + + $this->shareMapper->deleteById($id); + + return new DataResponse($id); + } +} diff --git a/lib/Db/FormMapper.php b/lib/Db/FormMapper.php index fd0bf34ae..044b3590c 100644 --- a/lib/Db/FormMapper.php +++ b/lib/Db/FormMapper.php @@ -34,6 +34,9 @@ class FormMapper extends QBMapper { /** @var QuestionMapper */ private $questionMapper; + /** @var ShareMapper */ + private $shareMapper; + /** @var SubmissionMapper */ private $submissionMapper; @@ -43,10 +46,12 @@ class FormMapper extends QBMapper { * @param IDBConnection $db */ public function __construct(QuestionMapper $questionMapper, + ShareMapper $shareMapper, SubmissionMapper $submissionMapper, IDBConnection $db) { parent::__construct($db, 'forms_v2_forms', Form::class); $this->questionMapper = $questionMapper; + $this->shareMapper = $shareMapper; $this->submissionMapper = $submissionMapper; } @@ -118,12 +123,13 @@ public function findAllByOwnerId(string $ownerId): array { } /** - * Delete a Form including connected Questions and Submissions + * Delete a Form including connected Questions, Submissions and shares. * @param Form $form The form instance to delete */ public function deleteForm(Form $form): void { - // Delete Submissions(incl. Answers), Questions(incl. Options) and Form. + // Delete Submissions(incl. Answers), Questions(incl. Options), Shares and Form. $this->submissionMapper->deleteByForm($form->getId()); + $this->shareMapper->deleteByForm($form->getId()); $this->questionMapper->deleteByForm($form->getId()); $this->delete($form); } diff --git a/lib/Db/Share.php b/lib/Db/Share.php new file mode 100644 index 000000000..c4861499d --- /dev/null +++ b/lib/Db/Share.php @@ -0,0 +1,64 @@ + + * + * @author Jonas Rittershofer + * + * @license AGPL-3.0-or-later + * + * 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 . + * + */ + +namespace OCA\Forms\Db; + +use OCP\AppFramework\Db\Entity; + +/** + * @method integer getFormId() + * @method void setFormId(integer $value) + * @method integer getShareType() + * @method void setShareType(integer $value) + * @method string getShareWith() + * @method void setShareWith(string $value) + */ +class Share extends Entity { + /** @var int */ + protected $formId; + /** @var int */ + protected $shareType; + /** @var string */ + protected $shareWith; + + /** + * Option constructor. + */ + public function __construct() { + $this->addType('formId', 'integer'); + $this->addType('shareType', 'integer'); + $this->addType('shareWith', 'string'); + } + + public function read(): array { + return [ + 'id' => $this->getId(), + 'formId' => $this->getFormId(), + 'shareType' => $this->getShareType(), + 'shareWith' => $this->getShareWith(), + ]; + } +} diff --git a/lib/Db/ShareMapper.php b/lib/Db/ShareMapper.php new file mode 100644 index 000000000..726fbdacc --- /dev/null +++ b/lib/Db/ShareMapper.php @@ -0,0 +1,131 @@ + + * + * @author Jonas Rittershofer + * + * @license AGPL-3.0-or-later + * + * 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 . + * + */ + +namespace OCA\Forms\Db; + +use OCP\AppFramework\Db\DoesNotExistException; +use OCP\AppFramework\Db\MultipleObjectsReturnedException; +use OCP\AppFramework\Db\QBMapper; +use OCP\DB\QueryBuilder\IQueryBuilder; +use OCP\IDBConnection; +use OCP\Share\IShare; + +class ShareMapper extends QBMapper { + /** + * ShareMapper constructor. + * @param IDBConnection $db + */ + public function __construct(IDBConnection $db) { + parent::__construct($db, 'forms_v2_shares', Share::class); + } + + /** + * Find a Share + * @param int $id + * @return Share + * @throws MultipleObjectsReturnedException if more than one result + * @throws DoesNotExistException if not found + */ + public function findById(int $id): Share { + $qb = $this->db->getQueryBuilder(); + + $qb->select('*') + ->from($this->getTableName()) + ->where( + $qb->expr()->eq('id', $qb->createNamedParameter($id, IQueryBuilder::PARAM_INT)) + ); + + return $this->findEntity($qb); + } + + /** + * Find Shares corresponding to a form. + * @param int $formId + * @return Share[] + */ + public function findByForm(int $formId): array { + $qb = $this->db->getQueryBuilder(); + + $qb->select('*') + ->from($this->getTableName()) + ->where( + $qb->expr()->eq('form_id', $qb->createNamedParameter($formId, IQueryBuilder::PARAM_INT)) + ) + ->orderBy('share_type', 'ASC'); //Already order by ShareType + + return $this->findEntities($qb); + } + + /** + * Find Public Share by Hash + * @param string $hash + * @return Share + * @throws MultipleObjectsReturnedException if more than one result + * @throws DoesNotExistException if not found + */ + public function findPublicShareByHash(string $hash): Share { + $qb = $this->db->getQueryBuilder(); + + $qb->select('*') + ->from($this->getTableName()) + ->where( + $qb->expr()->eq('share_type', $qb->createNamedParameter(IShare::TYPE_LINK, IQueryBuilder::PARAM_INT)) + ) + ->andWhere( + $qb->expr()->eq('share_with', $qb->createNamedParameter($hash, IQueryBuilder::PARAM_STR)) + ); + + return $this->findEntity($qb); + } + + /** + * Delete a share + * @param int $id of the share. + */ + public function deleteById(int $id): void { + $qb = $this->db->getQueryBuilder(); + + $qb->delete($this->getTableName()) + ->where( + $qb->expr()->eq('id', $qb->createNamedParameter($id, IQueryBuilder::PARAM_INT)) + ); + $qb->execute(); + } + + /** + * Delete all Shares of a form. + * @param int $formId + */ + public function deleteByForm(int $formId): void { + $qb = $this->db->getQueryBuilder(); + + $qb->delete($this->getTableName()) + ->where( + $qb->expr()->eq('form_id', $qb->createNamedParameter($formId, IQueryBuilder::PARAM_INT)) + ); + $qb->execute(); + } +} diff --git a/lib/Migration/Version030000Date20211206213004.php b/lib/Migration/Version030000Date20211206213004.php new file mode 100644 index 000000000..2e79e406f --- /dev/null +++ b/lib/Migration/Version030000Date20211206213004.php @@ -0,0 +1,185 @@ + + * + * @author Jonas Rittershofer + * + * @license AGPL-3.0-or-later + * + * 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 . + * + */ + +namespace OCA\Forms\Migration; + +use Closure; +use OCP\DB\ISchemaWrapper; +use OCP\DB\QueryBuilder\IQueryBuilder; +use OCP\DB\Types; +use OCP\IDBConnection; +use OCP\Migration\IOutput; +use OCP\Migration\SimpleMigrationStep; +use OCP\Share\IShare; + +class Version030000Date20211206213004 extends SimpleMigrationStep { + + /** @var IDBConnection */ + protected $connection; + + /** + * @param IDBConnection $connection + */ + public function __construct(IDBConnection $connection) { + $this->connection = $connection; + } + + /** + * @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): ?ISchemaWrapper { + /** @var ISchemaWrapper $schema */ + $schema = $schemaClosure(); + + // Abort if already existing. + if ($schema->hasTable('forms_v2_shares')) { + return null; + } + + // Create table + $table = $schema->createTable('forms_v2_shares'); + $table->addColumn('id', Types::INTEGER, [ + 'autoincrement' => true, + 'notnull' => true, + ]); + $table->addColumn('form_id', Types::INTEGER, [ + 'notnull' => true, + ]); + $table->addColumn('share_type', Types::SMALLINT, [ + 'notnull' => true, + ]); + $table->addColumn('share_with', Types::STRING, [ + 'length' => 256, + ]); + + $table->setPrimaryKey(['id']) + ->addIndex(['form_id'], 'forms_shares_form') + ->addIndex(['share_type'], 'forms_shares_type') + ->addIndex(['share_with'], 'forms_shares_with'); + + return $schema; + } + + /** + * Migrate the currently active old sharing to the new sharing. + * -> 'public' gets mapped to: legacy-link + * -> 'registered' mapped to: permit & show to all + * -> 'selected' mapped to: selected shares + * + * @param IOutput $output + * @param Closure $schemaClosure The `\Closure` returns a `ISchemaWrapper` + * @param array $options + */ + public function postSchemaChange(IOutput $output, Closure $schemaClosure, array $options): void { + $qbFetch = $this->connection->getQueryBuilder(); + $qbUpdateAccess = $this->connection->getQueryBuilder(); + $qbInsertShares = $this->connection->getQueryBuilder(); + + // Prepare Queries + $qbFetch->select('id', 'access_json') + ->from('forms_v2_forms'); + + $qbUpdateAccess->update('forms_v2_forms') + ->set('access_json', $qbUpdateAccess->createParameter('access_json')) + ->where($qbUpdateAccess->expr()->eq('id', $qbUpdateAccess->createParameter('id'))); + + $qbInsertShares->insert('forms_v2_shares') + ->values([ + 'form_id' => $qbInsertShares->createParameter('form_id'), + 'share_type' => $qbInsertShares->createParameter('share_type'), + 'share_with' => $qbInsertShares->createParameter('share_with'), + ]); + + // Fetch Forms... + $cursor = $qbFetch->executeQuery(); + + // ... then handle each existing form and translate its sharing settings. + while ($row = $cursor->fetch()) { + // Decode access to array (param assoc=true) + $access = json_decode($row['access_json'], true); + + // In case there are already migrated forms, just skip. + if (array_key_exists('permitAllUsers', $access)) { + $output->warning('Already migrated form: ' . $row['id'] . ', access: ' . $row['access_json']); + continue; + } + + switch ($access['type']) { + case 'public': + $newAccess = [ + 'legacyLink' => true, + 'permitAllUsers' => false, + 'showToAllUsers' => false, + ]; + $qbUpdateAccess->setParameter('id', $row['id'], IQueryBuilder::PARAM_INT) + ->setParameter('access_json', json_encode($newAccess), IQueryBuilder::PARAM_STR) + ->executeStatement(); + break; + + case 'registered': + $newAccess = [ + 'permitAllUsers' => true, + 'showToAllUsers' => true, + ]; + $qbUpdateAccess->setParameter('id', $row['id'], IQueryBuilder::PARAM_INT) + ->setParameter('access_json', json_encode($newAccess), IQueryBuilder::PARAM_STR) + ->executeStatement(); + break; + + case 'selected': + $newAccess = [ + 'permitAllUsers' => false, + 'showToAllUsers' => false, + ]; + $qbUpdateAccess->setParameter('id', $row['id'], IQueryBuilder::PARAM_INT) + ->setParameter('access_json', json_encode($newAccess), IQueryBuilder::PARAM_STR) + ->executeStatement(); + + // Insert single selected shares. + foreach ($access['users'] as $user) { + $qbInsertShares->setParameter('form_id', $row['id'], IQueryBuilder::PARAM_INT) + ->setParameter('share_type', IShare::TYPE_USER, IQueryBuilder::PARAM_INT) + ->setParameter('share_with', $user, IQueryBuilder::PARAM_STR) + ->executeStatement(); + } + foreach ($access['groups'] as $group) { + $qbInsertShares->setParameter('form_id', $row['id'], IQueryBuilder::PARAM_INT) + ->setParameter('share_type', IShare::TYPE_GROUP, IQueryBuilder::PARAM_INT) + ->setParameter('share_with', $group, IQueryBuilder::PARAM_STR) + ->executeStatement(); + } + break; + + default: + $output->warning('Unknown access property on form. ID: ' . $row['id'] . ', access: ' . $row['access_json']); + } + } + $cursor->closeCursor(); + } +} diff --git a/lib/Service/FormsService.php b/lib/Service/FormsService.php index 9d90c8aad..b3084d5e4 100644 --- a/lib/Service/FormsService.php +++ b/lib/Service/FormsService.php @@ -25,10 +25,13 @@ namespace OCA\Forms\Service; use OCA\Forms\Activity\ActivityManager; +use OCA\Forms\Constants; use OCA\Forms\Db\Form; use OCA\Forms\Db\FormMapper; use OCA\Forms\Db\OptionMapper; use OCA\Forms\Db\QuestionMapper; +use OCA\Forms\Db\Share; +use OCA\Forms\Db\ShareMapper; use OCA\Forms\Db\SubmissionMapper; use OCP\AppFramework\Db\DoesNotExistException; use OCP\AppFramework\Db\IMapperException; @@ -57,6 +60,9 @@ class FormsService { /** @var QuestionMapper */ private $questionMapper; + /** @var ShareMapper */ + private $shareMapper; + /** @var SubmissionMapper */ private $submissionMapper; @@ -76,6 +82,7 @@ public function __construct(ActivityManager $activityManager, FormMapper $formMapper, OptionMapper $optionMapper, QuestionMapper $questionMapper, + ShareMapper $shareMapper, SubmissionMapper $submissionMapper, IGroupManager $groupManager, ILogger $logger, @@ -85,6 +92,7 @@ public function __construct(ActivityManager $activityManager, $this->formMapper = $formMapper; $this->optionMapper = $optionMapper; $this->questionMapper = $questionMapper; + $this->shareMapper = $shareMapper; $this->submissionMapper = $submissionMapper; $this->groupManager = $groupManager; $this->logger = $logger; @@ -135,6 +143,25 @@ public function getQuestions(int $formId): array { } } + /** + * Load shares corresponding to form + * + * @param integer $formId + * @return array + */ + public function getShares(int $formId): array { + $shareList = []; + + $shareEntities = $this->shareMapper->findByForm($formId); + foreach ($shareEntities as $shareEntity) { + $share = $shareEntity->read(); + $share['displayName'] = $this->getShareDisplayName($share); + $shareList[] = $share; + } + + return $shareList; + } + /** * Get a form data * @@ -146,20 +173,36 @@ public function getForm(int $id): array { $form = $this->formMapper->findById($id); $result = $form->read(); $result['questions'] = $this->getQuestions($id); + $result['shares'] = $this->getShares($id); - // Set proper user/groups properties - // Make sure we have the bare minimum - $result['access'] = array_merge(['users' => [], 'groups' => []], $result['access']); - // Properly format users & groups - $result['access']['users'] = array_map([$this, 'formatUsers'], $result['access']['users']); - $result['access']['groups'] = array_map([$this, 'formatGroups'], $result['access']['groups']); - + // Append permissions for current user. + $result['permissions'] = $this->getPermissions($id); // Append canSubmit, to be able to show proper EmptyContent on internal view. $result['canSubmit'] = $this->canSubmit($form->getId()); return $result; } + /** + * Create partial form, as returned by Forms-Lists. + * + * @param integer $id + * @return array + * @throws IMapperException + */ + public function getPartialFormArray(int $id): array { + $form = $this->formMapper->findById($id); + + return [ + 'id' => $form->getId(), + 'hash' => $form->getHash(), + 'title' => $form->getTitle(), + 'expires' => $form->getExpires(), + 'permissions' => $this->getPermissions($form->getId()), + 'partial' => true + ]; + } + /** * Get a form data without sensitive informations * @@ -173,19 +216,45 @@ public function getPublicForm(int $id): array { // Remove sensitive data unset($form['access']); unset($form['ownerId']); + unset($form['shares']); return $form; } + /** + * Get current users permissions on a form + * + * @param integer $formId + * @return array + */ + public function getPermissions(int $formId): array { + $form = $this->formMapper->findById($formId); + + // Owner is allowed to do everything + if ($this->currentUser && $this->currentUser->getUID() === $form->getOwnerId()) { + return Constants::PERMISSION_ALL; + } + + $permissions = []; + // Add submit permission if user has access. + if ($this->hasUserAccess($formId)) { + $permissions[] = Constants::PERMISSION_SUBMIT; + } + + return $permissions; + } + /** * Can the user submit a form + * + * @param integer $formId + * @return boolean */ - public function canSubmit($formId) { + public function canSubmit(int $formId): bool { $form = $this->formMapper->findById($formId); - $access = $form->getAccess(); - // We cannot control how many time users can submit in public mode - if ($access['type'] === 'public') { + // We cannot control how many time users can submit if public link / legacyLink available + if ($this->hasPublicLink($formId)) { return true; } @@ -208,21 +277,41 @@ public function canSubmit($formId) { } /** - * Check if user has access to this form + * Searching Shares for public link * * @param integer $formId * @return boolean */ - public function hasUserAccess(int $formId): bool { + public function hasPublicLink(int $formId): bool { $form = $this->formMapper->findById($formId); $access = $form->getAccess(); - $ownerId = $form->getOwnerId(); - if ($access['type'] === 'public') { + if (isset($access['legacyLink'])) { return true; } - - // Refuse access, if not public and no user logged in. + + $shareEntities = $this->shareMapper->findByForm($form->getId()); + foreach ($shareEntities as $shareEntity) { + if ($shareEntity->getShareType() === IShare::TYPE_LINK) { + return true; + } + } + + return false; + } + + /** + * Check if current user has access to this form + * + * @param integer $formId + * @return boolean + */ + public function hasUserAccess(int $formId): bool { + $form = $this->formMapper->findById($formId); + $access = $form->getAccess(); + $ownerId = $form->getOwnerId(); + + // Refuse access, if no user logged in. if (!$this->currentUser) { return false; } @@ -232,25 +321,83 @@ public function hasUserAccess(int $formId): bool { return true; } - // Now all remaining users are allowed, if access-type 'registered'. - if ($access['type'] === 'registered') { + // Now all remaining users are allowed, if permitAll is set. + if ($access['permitAllUsers']) { return true; } // Selected Access remains. - // Grant Access, if user is in users-Array. - if (in_array($this->currentUser->getUID(), $access['users'])) { + if ($this->isSharedToUser($formId)) { return true; } - // Check if access granted by group. - foreach ($access['groups'] as $group) { - if ($this->groupManager->isInGroup($this->currentUser->getUID(), $group)) { - return true; + // None of the possible access-options matched. + return false; + } + + /** + * Is the form shown on sidebar to the user. + * + * @param int $formId + * @return bool + */ + public function isSharedFormShown(int $formId): bool { + $form = $this->formMapper->findById($formId); + $access = $form->getAccess(); + + // Dont show here to owner, as its in the owned list anyways. + if ($form->getOwnerId() === $this->currentUser->getUID()) { + return false; + } + + // Dont show expired forms. + if ($this->hasFormExpired($form->getId())) { + return false; + } + + // Shown if permitall and showntoall are both set. + if ($access['permitAllUsers'] && $access['showToAllUsers']) { + return true; + } + + // Shown if user in List of Shared Users/Groups + if ($this->isSharedToUser($formId)) { + return true; + } + + // No Reason found to show form. + return false; + } + + /** + * Checking all selected shares + * + * @param $formId + * @return bool + */ + public function isSharedToUser(int $formId): bool { + $shareEntities = $this->shareMapper->findByForm($formId); + foreach ($shareEntities as $shareEntity) { + $share = $shareEntity->read(); + + // Needs different handling for shareTypes + switch ($share['shareType']) { + case IShare::TYPE_USER: + if ($share['shareWith'] === $this->currentUser->getUID()) { + return true; + } + break; + case IShare::TYPE_GROUP: + if ($this->groupManager->isInGroup($this->currentUser->getUID(), $share['shareWith'])) { + return true; + } + break; + default: + // Return false below } } - // None of the possible access-options matched. + // No share found. return false; } @@ -266,63 +413,49 @@ public function hasFormExpired(int $formId): bool { } /** - * Format users access + * Get DisplayNames to Shares * - * @param string $userId - * @return array + * @param array $share + * @return string */ - private function formatUsers(string $userId): array { + public function getShareDisplayName(array $share): string { $displayName = ''; - $user = $this->userManager->get($userId); - if ($user instanceof IUser) { - $displayName = $user->getDisplayName(); - } - - return [ - 'shareWith' => $userId, - 'displayName' => $displayName, - 'shareType' => IShare::TYPE_USER - ]; - } - - /** - * Format groups access - * - * @param string $groupId - * @return array - */ - private function formatGroups(string $groupId): array { - $displayName = ''; - - $group = $this->groupManager->get($groupId); - if ($group instanceof IGroup) { - $displayName = $group->getDisplayName(); + switch ($share['shareType']) { + case IShare::TYPE_USER: + $user = $this->userManager->get($share['shareWith']); + if ($user instanceof IUser) { + $displayName = $user->getDisplayName(); + } + break; + case IShare::TYPE_GROUP: + $group = $this->groupManager->get($share['shareWith']); + if ($group instanceof IGroup) { + $displayName = $group->getDisplayName(); + } + break; + default: + // Preset Empty. } - return [ - 'shareWith' => $groupId, - 'displayName' => $displayName, - 'shareType' => IShare::TYPE_GROUP - ]; + return $displayName; } /** - * Compares two selected access arrays and creates activities for users. + * Creates activities for sharing to users. * @param Form $form Related Form - * @param array $oldAccess old access-array - * @param array $newAccess new access-array + * @param Share $share The new Share */ - public function notifyNewShares(Form $form, array $oldAccess, array $newAccess) { - $newUsers = array_diff($newAccess['users'], $oldAccess['users']); - $newGroups = array_diff($newAccess['groups'], $oldAccess['groups']); - - // Create Activities - foreach ($newUsers as $key => $newUserId) { - $this->activityManager->publishNewShare($form, $newUserId); - } - foreach ($newGroups as $key => $newGroupId) { - $this->activityManager->publishNewGroupShare($form, $newGroupId); + public function notifyNewShares(Form $form, Share $share): void { + switch ($share->getShareType()) { + case IShare::TYPE_USER: + $this->activityManager->publishNewShare($form, $share->getShareWith()); + break; + case IShare::TYPE_GROUP: + $this->activityManager->publishNewGroupShare($form, $share->getShareWith()); + break; + default: + // Do nothing. } } } diff --git a/package-lock.json b/package-lock.json index 6d10acc57..e2e5121ee 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "forms", - "version": "2.5.0", + "version": "3.0.0-alpha.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "forms", - "version": "2.5.0", + "version": "3.0.0-alpha.0", "license": "AGPL-3.0", "dependencies": { "@nextcloud/auth": "^1.3.0", diff --git a/package.json b/package.json index f823ff14c..28872c9df 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "forms", "description": "Forms app for nextcloud", - "version": "2.5.0", + "version": "3.0.0-alpha.0", "repository": { "type": "git", "url": "git+https://github.com/nextcloud/forms.git" diff --git a/src/Forms.vue b/src/Forms.vue index e1f764333..c9f294fc0 100644 --- a/src/Forms.vue +++ b/src/Forms.vue @@ -32,6 +32,7 @@ :key="form.id" :form="form" :read-only="false" + @open-sharing="openSharing" @mobile-close-navigation="mobileCloseNavigation" @clone="onCloneForm" @delete="onDeleteForm" /> @@ -73,10 +74,12 @@ @@ -97,6 +100,7 @@ import isMobile from '@nextcloud/vue/dist/Mixins/isMobile' import AppNavigationForm from './components/AppNavigationForm.vue' import EmptyContent from './components/EmptyContent.vue' +import PermissionTypes from './mixins/PermissionTypes.js' import OcsResponse2Data from './utils/OcsResponse2Data.js' export default { @@ -112,12 +116,13 @@ export default { EmptyContent, }, - mixins: [isMobile], + mixins: [isMobile, PermissionTypes], data() { return { loading: true, sidebarOpened: false, + sidebarActive: 'forms-sharing', forms: [], sharedForms: [], } @@ -140,20 +145,23 @@ export default { // If the user is allowed to access this route routeAllowed() { - // If the form is not within the owned or shared list, the user has no access on internal view. - if (this.routeHash && this.forms.concat(this.sharedForms).findIndex(form => form.hash === this.routeHash) < 0) { - console.error('Form not found for hash: ', this.routeHash) + // Not allowed, if no hash + if (!this.routeHash) { return false } - // For Routes edit or results, the form must be in owned forms. - if (this.$route.name === 'edit' || this.$route.name === 'results') { - if (this.forms.findIndex(form => form.hash === this.routeHash) < 0) { - return false - } + // Try to find form in owned & shared list + const form = [...this.forms, ...this.sharedForms] + .find(form => form.hash === this.routeHash) + + // If no form found, load it from server. Route will be automatically re-evaluated. + if (form === undefined) { + this.fetchPartialForm(this.routeHash) + return false } - return true + // Return whether route is in the permissions-list + return form?.permissions.includes(this.$route.name) }, selectedForm: { @@ -193,6 +201,19 @@ export default { } }, + /** + * Open a form and its sidebar for sharing + * + * @param {string} hash the hash of the form to load + */ + openSharing(hash) { + if (hash !== this.routeHash || this.$route.name !== 'edit') { + this.$router.push({ name: 'edit', params: { hash } }) + } + this.sidebarActive = 'forms-sharing' + this.sidebarOpened = true + }, + /** * Initial forms load */ @@ -220,6 +241,30 @@ export default { this.loading = false }, + /** + * Fetch a partial form by its hash and add it to the shared forms list. + * + * @param {string} hash the hash of the form to load + */ + async fetchPartialForm(hash) { + this.loading = true + + try { + const response = await axios.get(generateOcsUrl('apps/forms/api/v2/partial_form/{hash}', { hash })) + const form = OcsResponse2Data(response) + + // If the user has (at least) submission-permissions, add it to the shared forms + if (form.permissions.includes(this.PERMISSION_TYPES.PERMISSION_SUBMIT)) { + this.sharedForms.push(form) + } + } catch (error) { + showError(t('forms', 'Form not found')) + console.error(error) + } finally { + this.loading = false + } + }, + /** * */ diff --git a/src/FormsSubmit.vue b/src/FormsSubmit.vue index 08596567b..3e6f70016 100644 --- a/src/FormsSubmit.vue +++ b/src/FormsSubmit.vue @@ -24,6 +24,7 @@ @@ -45,6 +46,7 @@ export default { return { form: loadState('forms', 'form'), isLoggedIn: loadState('forms', 'isLoggedIn'), + shareHash: loadState('forms', 'shareHash'), } }, } diff --git a/src/components/AppNavigationForm.vue b/src/components/AppNavigationForm.vue index 24e80e678..bb9300767 100644 --- a/src/components/AppNavigationForm.vue +++ b/src/components/AppNavigationForm.vue @@ -30,12 +30,9 @@ }" @click="mobileCloseNavigation"> @@ -220,6 +220,10 @@ export default { }) }, + onShareForm() { + this.$emit('open-sharing', this.form.hash) + }, + async loadFormResults() { this.loadingResults = true console.debug('Loading results for form', this.form.hash) diff --git a/src/views/Sidebar.vue b/src/views/Sidebar.vue index d220121d8..95b4b261d 100644 --- a/src/views/Sidebar.vue +++ b/src/views/Sidebar.vue @@ -22,115 +22,36 @@ diff --git a/src/views/Submit.vue b/src/views/Submit.vue index 0d3f4cbe0..0f0b857dd 100644 --- a/src/views/Submit.vue +++ b/src/views/Submit.vue @@ -112,6 +112,10 @@ export default { required: false, default: true, }, + shareHash: { + type: String, + default: '', + }, }, data() { @@ -230,6 +234,7 @@ export default { await axios.post(generateOcsUrl('apps/forms/api/v1.1/submission/insert'), { formId: this.form.id, answers: this.answers, + shareHash: this.shareHash, }) this.success = true } catch (error) { diff --git a/tests/Unit/Controller/ShareApiControllerTest.php b/tests/Unit/Controller/ShareApiControllerTest.php new file mode 100644 index 000000000..65e8053e3 --- /dev/null +++ b/tests/Unit/Controller/ShareApiControllerTest.php @@ -0,0 +1,438 @@ + + * + * @author Jonas Rittershofer + * + * @license AGPL-3.0-or-later + * + * 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 . + * + */ +namespace OCA\Forms\Tests\Unit\Controller; + +use OCA\Forms\Controller\ShareApiController; + +use OCA\Forms\Db\Form; +use OCA\Forms\Db\FormMapper; +use OCA\Forms\Db\Share; +use OCA\Forms\Db\ShareMapper; +use OCA\Forms\Service\FormsService; + +use OCP\AppFramework\Db\DoesNotExistException; +use OCP\AppFramework\Http\DataResponse; +use OCP\AppFramework\OCS\OCSBadRequestException; +use OCP\AppFramework\OCS\OCSException; +use OCP\AppFramework\OCS\OCSForbiddenException; +use OCP\IGroup; +use OCP\IGroupManager; +use OCP\ILogger; +use OCP\IRequest; +use OCP\IUser; +use OCP\IUserManager; +use OCP\IUserSession; +use OCP\Security\ISecureRandom; +use OCP\Share\IShare; + +use PHPUnit\Framework\MockObject\MockObject; +use Test\TestCase; + +class ShareApiControllerTest extends TestCase { + + /** @var ShareApiController */ + private $shareApiController; + + /** @var FormMapper|MockObject */ + private $formMapper; + + /** @var ShareMapper|MockObject */ + private $shareMapper; + + /** @var FormsService|MockObject */ + private $formsService; + + /** @var IGroupManager|MockObject */ + private $groupManager; + + /** @var ILogger|MockObject */ + private $logger; + + /** @var IRequest|MockObject */ + private $request; + + /** @var IUserManager|MockObject */ + private $userManager; + + /** @var ISecureRandom|MockObject */ + private $secureRandom; + + public function setUp(): void { + $this->formMapper = $this->createMock(FormMapper::class); + $this->shareMapper = $this->createMock(ShareMapper::class); + $this->formsService = $this->createMock(FormsService::class); + $this->groupManager = $this->createMock(IGroupManager::class); + $this->logger = $this->createMock(ILogger::class); + $this->request = $this->createMock(IRequest::class); + $this->userManager = $this->createMock(IUserManager::class); + $userSession = $this->createMock(IUserSession::class); + $this->secureRandom = $this->createMock(ISecureRandom::class); + + $user = $this->createMock(IUser::class); + $user->expects($this->any()) + ->method('getUID') + ->willReturn('currentUser'); + $userSession->expects($this->once()) + ->method('getUser') + ->willReturn($user); + + $this->shareApiController = new ShareApiController( + 'forms', + $this->formMapper, + $this->shareMapper, + $this->formsService, + $this->groupManager, + $this->logger, + $this->request, + $this->userManager, + $userSession, + $this->secureRandom + ); + } + + public function dataValidNewShare() { + return [ + 'newUserShare' => [ + 'shareType' => IShare::TYPE_USER, + 'shareWith' => 'user1', + 'expected' => [ + 'id' => 13, + 'formId' => 5, + 'shareType' => 0, + 'shareWith' => 'user1', + 'displayName' => 'user1 DisplayName' + ] + ], + 'newGroupShare' => [ + 'shareType' => IShare::TYPE_GROUP, + 'shareWith' => 'group1', + 'expected' => [ + 'id' => 13, + 'formId' => 5, + 'shareType' => 1, + 'shareWith' => 'group1', + 'displayName' => 'group1 DisplayName' + ] + ], + ]; + } + /** + * Test valid shares + * @dataProvider dataValidNewShare + * + * @param int $shareType + * @param string $shareWith + * @param array $expected + */ + public function testValidNewShare(int $shareType, string $shareWith, array $expected) { + $form = new Form(); + $form->setId('5'); + $form->setOwnerId('currentUser'); + + $this->formMapper->expects($this->once()) + ->method('findById') + ->with('5') + ->willReturn($form); + + $this->userManager->expects($this->any()) + ->method('get') + ->with($shareWith) + ->willReturn($this->createMock(IUser::class)); + + $this->groupManager->expects($this->any()) + ->method('get') + ->with($shareWith) + ->willReturn($this->createMock(IGroup::class)); + + $share = new Share(); + $share->setformId('5'); + $share->setShareType($shareType); + $share->setShareWith($shareWith); + $shareWithId = clone $share; + $shareWithId->setId(13); + $this->shareMapper->expects($this->once()) + ->method('insert') + ->with($share) + ->willReturn($shareWithId); + + $this->formsService->expects($this->once()) + ->method('getShareDisplayName') + ->with($shareWithId->read()) + ->willReturn($shareWith . ' DisplayName'); + + // Share Form '5' to 'user1' of share-type 'user=0' + $expectedResponse = new DataResponse($expected); + $this->assertEquals($expectedResponse, $this->shareApiController->newShare(5, $shareType, $shareWith)); + } + + public function dataNewLinkShare() { + return [ + 'newLinkShare' => [ + 'shareType' => IShare::TYPE_LINK, + 'shareWith' => '', + 'expected' => [ + 'id' => 13, + 'formId' => 5, + 'shareType' => 3, + 'shareWith' => 'abcdefgh', + 'displayName' => '' + ]], + ]; + } + /** + * Test valid Link shares + * @dataProvider dataNewLinkShare + * + * @param int $shareType + * @param string $shareWith + * @param array $expected + */ + public function testNewLinkShare(int $shareType, string $shareWith, array $expected) { + $form = new Form(); + $form->setId('5'); + $form->setOwnerId('currentUser'); + + $this->formMapper->expects($this->once()) + ->method('findById') + ->with('5') + ->willReturn($form); + + $this->secureRandom->expects($this->any()) + ->method('generate') + ->willReturn('abcdefgh'); + + $share = new Share(); + $share->setformId('5'); + $share->setShareType($shareType); + if ($shareWith === '') { + $share->setShareWith('abcdefgh'); + } else { + $share->setShareWith($shareWith); + } + $shareWithId = clone $share; + $shareWithId->setId(13); + $this->shareMapper->expects($this->once()) + ->method('insert') + ->with($share) + ->willReturn($shareWithId); + + $this->formsService->expects($this->once()) + ->method('getShareDisplayName') + ->with($shareWithId->read()) + ->willReturn(''); + + $this->shareMapper->expects($this->once()) + ->method('findPublicShareByHash') + ->will($this->throwException(new DoesNotExistException('Not found.'))); + + // Share the form. + $expectedResponse = new DataResponse($expected); + $this->assertEquals($expectedResponse, $this->shareApiController->newShare(5, $shareType, $shareWith)); + } + + /** + * Test a random link hash, that is already existing. + */ + public function testExistingLinkHash() { + $form = new Form(); + $form->setId('5'); + $form->setOwnerId('currentUser'); + + $this->formMapper->expects($this->once()) + ->method('findById') + ->with('5') + ->willReturn($form); + + $this->secureRandom->expects($this->any()) + ->method('generate') + ->willReturn('abcdefgh'); + + $this->shareMapper->expects($this->once()) + ->method('findPublicShareByHash') + ->with('abcdefgh') + ->willReturn(new Share()); + + $this->shareMapper->expects($this->never()) + ->method('insert'); + + $this->expectException(OCSException::class); + $this->shareApiController->newShare(5, IShare::TYPE_LINK, ''); + } + + /** + * Test an unused (but existing) Share-Type + */ + public function testBadShareType() { + $this->expectException(OCSBadRequestException::class); + + // Share Form '5' to 'user1' of share-type 'deck_user=13' + $this->shareApiController->newShare(5, 13, 'user1'); + } + + /** + * Test unknown user + */ + public function testBadUserShare() { + $form = new Form(); + $form->setId('5'); + $form->setOwnerId('currentUser'); + + $this->formMapper->expects($this->once()) + ->method('findById') + ->with('5') + ->willReturn($form); + + $this->userManager->expects($this->once()) + ->method('get') + ->with('noUser') + ->willReturn(null); + + $this->expectException(OCSBadRequestException::class); + + // Share Form '5' to 'noUser' of share-type 'user=0' + $this->shareApiController->newShare(5, 0, 'noUser'); + } + + /** + * Test unknown group + */ + public function testBadGroupShare() { + $form = new Form(); + $form->setId('5'); + $form->setOwnerId('currentUser'); + + $this->formMapper->expects($this->once()) + ->method('findById') + ->with('5') + ->willReturn($form); + + $this->groupManager->expects($this->once()) + ->method('get') + ->with('noGroup') + ->willReturn(null); + + $this->expectException(OCSBadRequestException::class); + + // Share Form '5' to 'noUser' of share-type 'group=1' + $this->shareApiController->newShare(5, 1, 'noGroup'); + } + + /** + * Sharing a non-existing form. + */ + public function testShareUnknownForm() { + $this->formMapper->expects($this->once()) + ->method('findById') + ->with('5') + ->will($this->throwException(new DoesNotExistException('Form not found'))); + ; + + $this->expectException(OCSBadRequestException::class); + $this->shareApiController->newShare(5, 0, 'user1'); + } + + /** + * Share form of other owner + */ + public function testShareForeignForm() { + $form = new Form(); + $form->setId('5'); + $form->setOwnerId('someOtherUser'); + + $this->formMapper->expects($this->once()) + ->method('findById') + ->with('5') + ->willReturn($form); + + $this->expectException(OCSForbiddenException::class); + $this->shareApiController->newShare(5, 0, 'user1'); + } + + /** + * Delete a share. + */ + public function testDeleteShare() { + $share = new Share(); + $share->setId(8); + $share->setFormId(5); + $this->shareMapper->expects($this->once()) + ->method('findById') + ->with('8') + ->willReturn($share); + + $form = new Form(); + $form->setId('5'); + $form->setOwnerId('currentUser'); + $this->formMapper->expects($this->once()) + ->method('findById') + ->with('5') + ->willReturn($form); + + $this->shareMapper->expects($this->once()) + ->method('deleteById') + ->with('8'); + + $response = new DataResponse(8); + $this->assertEquals($response, $this->shareApiController->deleteShare(8)); + } + + /** + * Delete Non-existing share. + */ + public function testDeleteUnknownShare() { + $this->shareMapper->expects($this->once()) + ->method('findById') + ->with('8') + ->will($this->throwException(new DoesNotExistException('Share not found'))); + ; + + $this->expectException(OCSBadRequestException::class); + $this->shareApiController->deleteShare(8); + } + + /** + * Delete share from form of other owner. + */ + public function testDeleteForeignShare() { + $share = new Share(); + $share->setId(8); + $share->setFormId(5); + $this->shareMapper->expects($this->once()) + ->method('findById') + ->with('8') + ->willReturn($share); + + $form = new Form(); + $form->setId('5'); + $form->setOwnerId('someOtherUser'); + $this->formMapper->expects($this->once()) + ->method('findById') + ->with('5') + ->willReturn($form); + + $this->expectException(OCSForbiddenException::class); + $this->shareApiController->deleteShare(8); + } +} diff --git a/tests/Unit/Service/FormsServiceTest.php b/tests/Unit/Service/FormsServiceTest.php index e4702c1d2..80585a83f 100644 --- a/tests/Unit/Service/FormsServiceTest.php +++ b/tests/Unit/Service/FormsServiceTest.php @@ -33,6 +33,8 @@ use OCA\Forms\Db\OptionMapper; use OCA\Forms\Db\Question; use OCA\Forms\Db\QuestionMapper; +use OCA\Forms\Db\Share; +use OCA\Forms\Db\ShareMapper; use OCA\Forms\Db\SubmissionMapper; use OCP\IGroup; @@ -41,6 +43,7 @@ use OCP\IUser; use OCP\IUserManager; use OCP\IUserSession; +use OCP\Share\IShare; use PHPUnit\Framework\MockObject\MockObject; use Test\TestCase; @@ -62,6 +65,9 @@ class FormsServiceTest extends TestCase { /** @var QuestionMapper|MockObject */ private $questionMapper; + /** @var ShareMapper|MockObject */ + private $shareMapper; + /** @var SubmissionMapper|MockObject */ private $submissionMapper; @@ -80,6 +86,7 @@ public function setUp(): void { $this->formMapper = $this->createMock(FormMapper::class); $this->optionMapper = $this->createMock(OptionMapper::class); $this->questionMapper = $this->createMock(QuestionMapper::class); + $this->shareMapper = $this->createMock(ShareMapper::class); $this->submissionMapper = $this->createMock(SubmissionMapper::class); $this->groupManager = $this->createMock(IGroupManager::class); @@ -100,6 +107,7 @@ public function setUp(): void { $this->formMapper, $this->optionMapper, $this->questionMapper, + $this->shareMapper, $this->submissionMapper, $this->groupManager, $this->logger, @@ -119,21 +127,8 @@ public function dataGetForm() { 'ownerId' => 'someUser', 'created' => 123456789, 'access' => [ - 'users' => [ - [ - 'shareWith' => 'user1', - 'displayName' => 'First User', - 'shareType' => 0 // IShare::TYPE_USER - ] - ], - 'groups' => [ - [ - 'shareWith' => 'group1', - 'displayName' => 'First Group', - 'shareType' => 1 // IShare::TYPE_GROUP - ] - ], - 'type' => 'selected' + 'permitAllUsers' => false, + 'showToAllUsers' => false, ], 'expires' => 0, 'isAnonymous' => false, @@ -171,6 +166,18 @@ public function dataGetForm() { 'description' => '', 'options' => [] ] + ], + 'shares' => [ + [ + 'id' => 1, + 'formId' => 42, + 'shareType' => 0, + 'shareWith' => 'currentUser', + 'displayName' => 'Current User' + ] + ], + 'permissions' => [ + 'submit' ] ]] ]; @@ -191,9 +198,8 @@ public function testGetForm(array $expected) { $form->setOwnerId('someUser'); $form->setCreated(123456789); $form->setAccess([ - 'users' => ['user1'], - 'groups' => ['group1'], - 'type' => 'selected' + 'permitAllUsers' => false, + 'showToAllUsers' => false, ]); $form->setExpires(0); $form->setIsAnonymous(false); @@ -208,19 +214,11 @@ public function testGetForm(array $expected) { $user = $this->createMock(IUser::class); $user->expects($this->once()) ->method('getDisplayName') - ->willReturn('First User'); - $group = $this->createMock(IGroup::class); - $group->expects($this->once()) - ->method('getDisplayName') - ->willReturn('First Group'); + ->willReturn('Current User'); $this->userManager->expects($this->once()) ->method('get') - ->with('user1') + ->with('currentUser') ->willReturn($user); - $this->groupManager->expects($this->once()) - ->method('get') - ->with('group1') - ->willReturn($group); // Questions $question1 = new Question(); @@ -258,10 +256,55 @@ public function testGetForm(array $expected) { ->with(1) ->willReturn([$option1, $option2]); + $share = new Share(); + $share->setId(1); + $share->setFormId(42); + $share->setShareType(0); + $share->setShareWith('currentUser'); + + $this->shareMapper->expects($this->exactly(3)) + ->method('findByForm') + ->with(42) + ->willReturn([$share]); + // Run the test $this->assertEquals($expected, $this->formsService->getForm(42)); } + public function dataGetPartialForm() { + return [ + 'onePartialOwnedForm' => [[ + 'id' => 42, + 'hash' => 'abcdefg', + 'title' => 'Form 1', + 'expires' => 0, + 'permissions' => ['edit', 'results', 'submit'], + 'partial' => true + ]] + ]; + } + /** + * @dataProvider dataGetPartialForm + * + * @param array $expected + */ + public function testGetPartialForm(array $expected) { + $form = new Form(); + $form->setId(42); + $form->setHash('abcdefg'); + $form->setTitle('Form 1'); + $form->setOwnerId('currentUser'); + $form->setExpires(0); + + $this->formMapper->expects($this->exactly(2)) + ->method('findById') + ->with(42) + ->willReturn($form); + + // Run the test + $this->assertEquals($expected, $this->formsService->getPartialFormArray(42)); + } + public function dataGetPublicForm() { return [ // Bare form without questions, checking removed access & ownerId @@ -275,7 +318,10 @@ public function dataGetPublicForm() { 'isAnonymous' => false, 'submitOnce' => false, 'canSubmit' => true, - 'questions' => [] + 'questions' => [], + 'permissions' => [ + 'submit' + ] ]] ]; } @@ -294,9 +340,8 @@ public function testGetPublicForm(array $expected) { $form->setOwnerId('someUser'); $form->setCreated(123456789); $form->setAccess([ - 'users' => ['user1'], - 'groups' => ['group1'], - 'type' => 'selected' + 'permitAllUsers' => false, + 'showToAllUsers' => false, ]); $form->setExpires(0); $form->setIsAnonymous(false); @@ -311,19 +356,11 @@ public function testGetPublicForm(array $expected) { $user = $this->createMock(IUser::class); $user->expects($this->once()) ->method('getDisplayName') - ->willReturn('First User'); - $group = $this->createMock(IGroup::class); - $group->expects($this->once()) - ->method('getDisplayName') - ->willReturn('First Group'); + ->willReturn('Current User'); $this->userManager->expects($this->once()) ->method('get') - ->with('user1') + ->with('currentUser') ->willReturn($user); - $this->groupManager->expects($this->once()) - ->method('get') - ->with('group1') - ->willReturn($group); // No Questions here $this->questionMapper->expects($this->once()) @@ -331,66 +368,123 @@ public function testGetPublicForm(array $expected) { ->with(42) ->willReturn([]); + // Share exists, but should not be shown in the end. + $share = new Share(); + $share->setId(1); + $share->setFormId(42); + $share->setShareType(0); + $share->setShareWith('currentUser'); + + $this->shareMapper->expects($this->exactly(3)) + ->method('findByForm') + ->with(42) + ->willReturn([$share]); + // Run the test $this->assertEquals($expected, $this->formsService->getPublicForm(42)); } - public function dataCanSubmit() { + public function dataGetPermissions() { return [ - 'publicForm' => [ - ['type' => 'public'], - 'someUser', - true, - ['currentUser'], - true + 'ownerHasAllPermissions' => [ + 'ownerId' => 'currentUser', + 'access' => [ + 'permitAllUsers' => false, + 'showToAllUsers' => false, + ], + 'expected' => ['edit', 'results', 'submit'], + ], + 'allUsersCanSubmit' => [ + 'ownerId' => 'someOtherUser', + 'access' => [ + 'permitAllUsers' => true, + 'showToAllUsers' => false, + ], + 'expected' => ['submit'], ], + 'noPermission' => [ + 'ownerId' => 'someOtherUser', + 'access' => [ + 'permitAllUsers' => false, + 'showToAllUsers' => false, + ], + 'expected' => [], + ] + ]; + } + /** + * @dataProvider dataGetPermissions + * + * @param string $ownerId + * @param array $access + * @param array $expected + */ + public function testGetPermissions(string $ownerId, array $access, array $expected) { + $form = new Form(); + $form->setId(42); + $form->setOwnerId($ownerId); + $form->setAccess($access); + + $this->formMapper->expects($this->any()) + ->method('findById') + ->with(42) + ->willReturn($form); + + $this->shareMapper->expects($this->any()) + ->method('findByForm') + ->with(42) + ->willReturn([]); + + $this->assertEquals($expected, $this->formsService->getPermissions(42)); + } + + public function dataCanSubmit() { + return [ 'allowFormOwner' => [ - ['type' => 'registered'], - 'currentUser', - true, - ['currentUser'], - true + 'ownerId' => 'currentUser', + 'submitOnce' => true, + 'participantsArray' => ['currentUser'], + 'expected' => true ], 'submitOnceGood' => [ - ['type' => 'registered'], - 'someUser', - true, - ['notCurrentUser'], - true + 'ownerId' => 'someUser', + 'submitOnce' => true, + 'participantsArray' => ['notCurrentUser'], + 'expected' => true ], 'submitOnceNotGood' => [ - ['type' => 'registered'], - 'someUser', - true, - ['currentUser'], - false + 'ownerId' => 'someUser', + 'submitOnce' => true, + 'participantsArray' => ['notCurrentUser', 'currentUser'], + 'expected' => false ], - 'simpleAllowed' => [ - ['type' => 'registered'], - 'someUser', - false, - ['currentUser'], - true + 'submitMultiple' => [ + 'ownerId' => 'someUser', + 'submitOnce' => false, + 'participantsArray' => ['currentUser'], + 'expected' => true ] ]; } /** * @dataProvider dataCanSubmit * - * @param array $accessArray * @param string $ownerId * @param bool $submitOnce * @param array $participantsArray * @param bool $expected */ - public function testCanSubmit(array $accessArray, string $ownerId, bool $submitOnce, array $participantsArray, bool $expected) { + public function testCanSubmit(string $ownerId, bool $submitOnce, array $participantsArray, bool $expected) { $form = new Form(); $form->setId(42); - $form->setAccess($accessArray); + $form->setAccess([ + 'permitAllUsers' => false, + 'showToAllUsers' => false, + ]); $form->setOwnerId($ownerId); $form->setSubmitOnce($submitOnce); - $this->formMapper->expects($this->once()) + $this->formMapper->expects($this->any()) ->method('findById') ->with(42) ->willReturn($form); @@ -403,63 +497,136 @@ public function testCanSubmit(array $accessArray, string $ownerId, bool $submitO $this->assertEquals($expected, $this->formsService->canSubmit(42)); } - public function dataHasUserAccess() { + /** + * Test result, if hasPublicLink returns true due to public link share. + */ + public function testPublicCanSubmit() { + $form = new Form(); + $form->setId(42); + $form->setAccess([ + 'permitAllUsers' => false, + 'showToAllUsers' => false, + ]); + + $this->formMapper->expects($this->any()) + ->method('findById') + ->with(42) + ->willReturn($form); + + $share = new Share; + $share->setShareType(IShare::TYPE_LINK); + $this->shareMapper->expects($this->once()) + ->method('findByForm') + ->with(42) + ->willReturn([$share]); + + // Make sure, we don't pass the PublicLinkCheck (which would then reach 'getUID') + $user = $this->createMock(IUser::class); + $user->expects($this->never()) + ->method('getUID'); + $userSession = $this->createMock(IUserSession::class); + $userSession->expects($this->once()) + ->method('getUser') + ->willReturn($user); + + $formsService = new FormsService( + $this->activityManager, + $this->formMapper, + $this->optionMapper, + $this->questionMapper, + $this->shareMapper, + $this->submissionMapper, + $this->groupManager, + $this->logger, + $this->userManager, + $userSession + ); + + $this->assertEquals(true, $formsService->canSubmit(42)); + } + + public function dataHasPublicLink() { return [ - 'noAccess' => [ - [ - 'type' => 'selected', - 'users' => [], - 'groups' => [] + 'legacyLink' => [ + 'access' => [ + 'legacyLink' => true, + 'permitAllUsers' => false, + 'showToAllUsers' => false, ], - 'someOtherUser', - false + 'shareType' => IShare::TYPE_USER, + 'expected' => true, ], - 'publicForm' => [ - [ - 'type' => 'public', - 'users' => [], - 'groups' => [] + 'noPublicLink' => [ + 'access' => [ + 'permitAllUsers' => false, + 'showToAllUsers' => false, ], - 'someOtherUser', - true + 'shareType' => IShare::TYPE_USER, + 'expected' => false, ], + 'hasPublicLink' => [ + 'access' => [ + 'permitAllUsers' => false, + 'showToAllUsers' => false, + ], + 'shareType' => IShare::TYPE_LINK, + 'expected' => true, + ] + ]; + } + /** + * @dataProvider dataHasPublicLink + * + * @param array $access + * @param int $shareType The ShareType used for this test. + * @param bool $expected + */ + public function testHasPublicLink(array $access, int $shareType, bool $expected) { + $form = new Form; + $form->setId(42); + $form->setAccess($access); + + $this->formMapper->expects($this->once()) + ->method('findById') + ->with(42) + ->willReturn($form); + + $share = new Share(); + $share->setShareType($shareType); + $this->shareMapper->expects($this->any()) + ->method('findByForm') + ->with(42) + ->willReturn([$share]); + + $this->assertEquals($expected, $this->formsService->hasPublicLink(42)); + } + + public function dataHasUserAccess() { + return [ 'ownerhasAccess' => [ - [ - 'type' => 'selected', - 'users' => [], - 'groups' => [] + 'accessArray' => [ + 'permitAllUsers' => false, + 'showToAllUsers' => false, ], - 'currentUser', - true + 'ownerId' => 'currentUser', + 'expected' => true ], - 'registeredHasAccess' => [ - [ - 'type' => 'registered', - 'users' => [], - 'groups' => [] + 'allUsersPermitted' => [ + 'accessArray' => [ + 'permitAllUsers' => true, + 'showToAllUsers' => false, ], - 'someOtherUser', - true + 'ownerId' => 'someOtherUser', + 'expected' => true ], - 'selectedUser' => [ - [ - 'type' => 'selected', - 'users' => ['user1', 'currentUser', 'user2'], - 'groups' => [] + 'noAccess' => [ + 'accessArray' => [ + 'permitAllUsers' => false, + 'showToAllUsers' => false, ], - 'someOtherUser', - true + 'ownerId' => 'someOtherUser', + 'expected' => false ], - 'userInSelectedGroup' => [ - [ - 'type' => 'selected', - 'users' => [], - 'groups' => ['currentUserGroup'] - ], - 'someOtherUser', - true - ] - ]; } /** @@ -479,14 +646,33 @@ public function testHasUserAccess(array $accessArray, string $ownerId, bool $exp ->with(42) ->willReturn($form); - $this->groupManager->expects($this->any()) - ->method('isInGroup') - ->with('currentUser', 'currentUserGroup') - ->willReturn(true); - $this->assertEquals($expected, $this->formsService->hasUserAccess(42)); } + public function testHasUserAccess_DirectShare() { + $form = new Form(); + $form->setAccess([ + 'permitAllUsers' => false, + 'showToAllUsers' => false, + ]); + $form->setOwnerId('notCurrentUser'); + + $this->formMapper->expects($this->once()) + ->method('findById') + ->with(42) + ->willReturn($form); + + $share = new Share(); + $share->setShareType(IShare::TYPE_USER); + $share->setShareWith('currentUser'); + $this->shareMapper->expects($this->any()) + ->method('findByForm') + ->with(42) + ->willReturn([$share]); + + $this->assertEquals(true, $this->formsService->hasUserAccess(42)); + } + public function testHasUserAccess_NotLoggedIn() { $userSession = $this->createMock(IUserSession::class); $userSession->expects($this->once()) @@ -498,6 +684,7 @@ public function testHasUserAccess_NotLoggedIn() { $this->formMapper, $this->optionMapper, $this->questionMapper, + $this->shareMapper, $this->submissionMapper, $this->groupManager, $this->logger, @@ -507,9 +694,8 @@ public function testHasUserAccess_NotLoggedIn() { $form = new Form(); $form->setAccess([ - 'type' => 'registered', - 'users' => [], - 'groups' => [] + 'permitAllUsers' => false, + 'showToAllUsers' => false, ]); $form->setOwnerId('someOtherUser'); @@ -521,6 +707,147 @@ public function testHasUserAccess_NotLoggedIn() { $this->assertEquals(false, $formsService->hasUserAccess(42)); } + public function dataIsSharedFormShown() { + return [ + 'dontShowToOwner' => [ + 'ownerId' => 'currentUser', + 'expires' => 0, + 'access' => [ + 'permitAllUsers' => true, + 'showToAllUsers' => true, + ], + 'shareType' => IShare::TYPE_LINK, + 'expected' => false, + ], + 'expiredForm' => [ + 'ownerId' => 'notCurrentUser', + 'expires' => 1, + 'access' => [ + 'permitAllUsers' => true, + 'showToAllUsers' => true, + ], + 'shareType' => IShare::TYPE_LINK, + 'expected' => false, + ], + 'shownToAll' => [ + 'ownerId' => 'notCurrentUser', + 'expires' => 0, + 'access' => [ + 'permitAllUsers' => true, + 'showToAllUsers' => true, + ], + 'shareType' => IShare::TYPE_LINK, + 'expected' => true, + ], + 'sharedToUser' => [ + 'ownerId' => 'notCurrentUser', + 'expires' => 0, + 'access' => [ + 'permitAllUsers' => false, + 'showToAllUsers' => false, + ], + 'shareType' => IShare::TYPE_USER, + 'expected' => true, + ], + 'notShown' => [ + 'ownerId' => 'notCurrentUser', + 'expires' => 0, + 'access' => [ + 'permitAllUsers' => true, + 'showToAllUsers' => false, + ], + 'shareType' => IShare::TYPE_LINK, + 'expected' => false, + ] + ]; + } + /** + * @dataProvider dataIsSharedFormShown + * + * @param string $ownerId + * @param int $expires + * @param array $access + * @param int $shareType ShareType used for dummy-share here. + * @param bool $expected + */ + public function testIsSharedFormShown(string $ownerId, int $expires, array $access, int $shareType, bool $expected) { + $form = new Form(); + $form->setId(42); + $form->setOwnerId($ownerId); + $form->setExpires($expires); + $form->setAccess($access); + + $this->formMapper->expects($this->any()) + ->method('findById') + ->with(42) + ->willReturn($form); + + $share = new Share(); + $share->setShareType($shareType); + $share->setShareWith('currentUser'); // Only relevant, if $shareType is TYPE_USER, otherwise it's just some 'hash' + $this->shareMapper->expects($this->any()) + ->method('findByForm') + ->with(42) + ->willReturn([$share]); + + $this->assertEquals($expected, $this->formsService->isSharedFormShown(42)); + } + + public function dataIsSharedToUser() { + return [ + 'sharedToUser' => [ + 'shareType' => IShare::TYPE_USER, + 'shareWith' => 'currentUser', + 'expected' => true, + ], + 'sharedToOtherUser' => [ + 'shareType' => IShare::TYPE_USER, + 'shareWith' => 'NotcurrentUser', + 'expected' => false, + ], + 'sharedToGroup' => [ + 'shareType' => IShare::TYPE_GROUP, + 'shareWith' => 'goodGroup', + 'expected' => true, + ], + 'sharedToOtherGroup' => [ + 'shareType' => IShare::TYPE_GROUP, + 'shareWith' => 'wrongGroup', + 'expected' => false, + ], + 'NotSharedToUser' => [ + 'shareType' => IShare::TYPE_LINK, + 'shareWith' => 'abcdefg', + 'expected' => false, + ], + ]; + } + /** + * @dataProvider dataIsSharedToUser + * + * @param int $shareType + * @param string $shareWith + * @param bool $expected + */ + public function testIsSharedToUser(int $shareType, string $shareWith, bool $expected) { + $share = new Share(); + $share->setShareType($shareType); + $share->setShareWith($shareWith); + $this->shareMapper->expects($this->once()) + ->method('findByForm') + ->with(42) + ->willReturn([$share]); + + $this->groupManager->expects($this->any()) + ->method('isInGroup') + ->will($this->returnValueMap([ + ['currentUser', 'goodGroup', true], + ['currentUser', 'wrongGroup', false], + ])); + + $this->assertEquals($expected, $this->formsService->isSharedToUser(42)); + } + public function dataHasFormExpired() { return [ 'hasExpired' => [time() - 3600, true], @@ -546,92 +873,90 @@ public function testHasFormExpired(int $expires, bool $expected) { $this->assertEquals($expected, $this->formsService->hasFormExpired(42)); } - public function dataNotifyNewShares() { + public function dataGetShareDisplayName() { return [ - 'newUsersGroups' => [ - [ - 'users' => ['user1'], - 'groups' => ['group1', 'group2'] + 'userShare' => [ + 'share' => [ + 'shareType' => IShare::TYPE_USER, + 'shareWith' => 'user1', ], - [ - 'users' => ['user1', 'user2', 'user3'], - 'groups' => ['group1', 'group2', 'group3'] - ], - ['user2', 'user3'], - ['group3'] + 'expected' => 'user1 UserDisplayname', ], - 'noNewShares' => [ - [ - 'users' => ['user1'], - 'groups' => ['group1', 'group2'] - ], - [ - 'users' => ['user1'], - 'groups' => ['group1', 'group2'] + 'groupShare' => [ + 'share' => [ + 'shareType' => IShare::TYPE_GROUP, + 'shareWith' => 'group1', ], - [], - [] + 'expected' => 'group1 GroupDisplayname', ], - 'removeShares' => [ - [ - 'users' => ['user1', 'user2', 'user3'], - 'groups' => ['group1', 'group2', 'group3'] + 'otherShare' => [ + 'share' => [ + 'shareType' => IShare::TYPE_LINK, + 'shareWith' => 'abcdefg', ], - [ - 'users' => ['user1'], - 'groups' => ['group1', 'group2'] - ], - [], - [] + 'expected' => '', ], - 'noSharesAtAll' => [ - [ - 'users' => [], - 'groups' => [] - ], - [ - 'users' => [], - 'groups' => [] - ], - [], - [] + ]; + } + /** + * @dataProvider dataGetShareDisplayName + * + * @param array $share + * @param string $expected + */ + public function testGetShareDisplayName(array $share, string $expected) { + $user = $this->createMock(IUser::class); + $user->expects($this->any()) + ->method('getDisplayName') + ->willReturn($share['shareWith'] . ' UserDisplayname'); + $this->userManager->expects($this->any()) + ->method('get') + ->with($share['shareWith']) + ->willReturn($user); + + $group = $this->createMock(IGroup::class); + $group->expects($this->any()) + ->method('getDisplayName') + ->willReturn($share['shareWith'] . ' GroupDisplayname'); + $this->groupManager->expects($this->any()) + ->method('get') + ->with($share['shareWith']) + ->willReturn($group); + + $this->assertEquals($expected, $this->formsService->getShareDisplayName($share)); + } + + public function dataNotifyNewShares() { + return [ + 'newUserShare' => [ + 'shareType' => IShare::TYPE_USER, + 'shareWith' => 'someUser', + 'expectedMethod' => 'publishNewShare', + ], + 'newGroupShare' => [ + 'shareType' => IShare::TYPE_GROUP, + 'shareWith' => 'someGroup', + 'expectedMethod' => 'publishNewGroupShare', ] ]; } /** * @dataProvider dataNotifyNewShares * - * @param array $oldAccess - * @param array $newAccess - * @param array $diffUsers - * @param array $diffGroups + * @param int $shareType + * @param string $shareWith + * @param string $expectedMethod that will be called on activityManager. */ - public function testNotifyNewShares(array $oldAccess, array $newAccess, array $diffUsers, array $diffGroups) { + public function testNotifyNewShares(int $shareType, string $shareWith, string $expectedMethod) { $form = $this->createMock(Form::class); + $share = new Share(); + $share->setShareType($shareType); + $share->setShareWith($shareWith); + + $this->activityManager->expects($this->once()) + ->method($expectedMethod) + ->with($form, $shareWith); - $passedUserList = []; - $this->activityManager->expects($this->any()) - ->method('publishNewShare') - ->will($this->returnCallback(function ($passedForm, $passedUser) use ($form, &$passedUserList) { - if ($passedForm === $form) { - // Store List of passed users - $passedUserList[] = $passedUser; - } - })); - $passedGroupList = []; - $this->activityManager->expects($this->any()) - ->method('publishNewGroupShare') - ->will($this->returnCallback(function ($passedForm, $passedGroup) use ($form, &$passedGroupList) { - if ($passedForm === $form) { - // Store List of passed groups - $passedGroupList[] = $passedGroup; - } - })); - - $this->formsService->notifyNewShares($form, $oldAccess, $newAccess); - - // Check List of called Users and Groups - $this->assertEquals($diffUsers, $passedUserList); - $this->assertEquals($diffGroups, $passedGroupList); + $this->formsService->notifyNewShares($form, $share); } }