diff --git a/docs/API_v3.md b/docs/API_v3.md index a3b127b07..4797da48a 100644 --- a/docs/API_v3.md +++ b/docs/API_v3.md @@ -92,7 +92,9 @@ Returns condensed objects of all Forms beeing owned by the authenticated user. "submit" ], "partial": true, - "state": 0 + "state": 0, + "lockedBy": null, + "lockedUntil": null }, { "id": 3, @@ -105,7 +107,9 @@ Returns condensed objects of all Forms beeing owned by the authenticated user. "submit" ], "partial": true, - "state": 0 + "state": 0, + "lockedBy": "someUser" + "lockedUntil": 123456789 } ] ``` @@ -169,6 +173,8 @@ Returns the full-depth object of the requested form (without submissions). "showExpiration": false, "canSubmit": true, "state": 0, + "lockedBy": null, + "lockedUntil": null, "permissions": [ "edit", "results", diff --git a/docs/DataStructure.md b/docs/DataStructure.md index bd9490585..ffd0cf42f 100644 --- a/docs/DataStructure.md +++ b/docs/DataStructure.md @@ -26,6 +26,8 @@ This document describes the Object-Structure, that is used within the Forms App | expires | unix-timestamp | | When the form should expire. Timestamp `0` indicates _never_ | | isAnonymous | Boolean | | If Answers will be stored anonymously | | state | Integer | [Form state](#form-state) | The state of the form | +| lockedBy | String | | The user ID for who has exclusive edit access at the moment | +| lockedUntil | unix timestamp | | When the form lock will expire | | submitMultiple | Boolean | | If users are allowed to submit multiple times to the form | | allowEditSubmissions | Boolean | | If users are allowed to edit or delete their response | | showExpiration | Boolean | | If the expiration date will be shown on the form | @@ -59,6 +61,8 @@ This document describes the Object-Structure, that is used within the Forms App ], "questions": [], "state": 0, + "lockedBy": null, + "lockedUntil": null, "shares": [] "submissions": [], "submissionCount": 0, diff --git a/img/lock_open.svg b/img/lock_open.svg new file mode 100644 index 000000000..e6d74fcfe --- /dev/null +++ b/img/lock_open.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/lib/Controller/ApiController.php b/lib/Controller/ApiController.php index dc0208340..c23f8efc0 100644 --- a/lib/Controller/ApiController.php +++ b/lib/Controller/ApiController.php @@ -22,7 +22,6 @@ use OCA\Forms\Db\SubmissionMapper; use OCA\Forms\Db\UploadedFile; use OCA\Forms\Db\UploadedFileMapper; -use OCA\Forms\Exception\NoSuchFormException; use OCA\Forms\ResponseDefinitions; use OCA\Forms\Service\ConfigService; use OCA\Forms\Service\FormsService; @@ -180,7 +179,7 @@ public function newForm(?int $fromId = null): DataResponse { $this->formMapper->insert($form); } else { - $oldForm = $this->getFormIfAllowed($fromId); + $oldForm = $this->formsService->getFormIfAllowed($fromId, Constants::PERMISSION_EDIT); // Read old form, (un)set new form specific data, extend title $formData = $oldForm->read(); @@ -190,6 +189,8 @@ public function newForm(?int $fromId = null): DataResponse { unset($formData['state']); unset($formData['fileId']); unset($formData['fileFormat']); + unset($formData['lockedBy']); + unset($formData['lockedUntil']); $formData['hash'] = $this->formsService->generateFormHash(); // TRANSLATORS Appendix to the form Title of a duplicated/copied form. $formData['title'] .= ' - ' . $this->l10n->t('Copy'); @@ -238,7 +239,7 @@ public function newForm(?int $fromId = null): DataResponse { #[BruteForceProtection(action: 'form')] #[ApiRoute(verb: 'GET', url: '/api/v3/forms/{formId}')] public function getForm(int $formId): DataResponse { - $form = $this->getFormIfAllowed($formId, Constants::PERMISSION_SUBMIT); + $form = $this->formsService->getFormIfAllowed($formId, Constants::PERMISSION_SUBMIT); return new DataResponse($this->formsService->getForm($form)); } @@ -267,72 +268,45 @@ public function updateForm(int $formId, array $keyValuePairs): DataResponse { 'keyValuePairs' => $keyValuePairs ]); - $form = $this->getFormIfAllowed($formId); - if ( - $this->formsService->isFormArchived($form) - && !( - sizeof($keyValuePairs) === 1 - && key_exists('state', $keyValuePairs) - && $keyValuePairs['state'] === Constants::FORM_STATE_CLOSED - ) - ) { - $this->logger->debug('This form is archived and can not be modified except to change state to closed.'); - throw new OCSForbiddenException('This form is archived and can not be modified except to change state to closed.'); - } + $form = $this->formsService->getFormIfAllowed($formId, Constants::PERMISSION_EDIT); + $currentUserId = $this->currentUser->getUID(); - // Don't allow empty array - if (sizeof($keyValuePairs) === 0) { + if (empty($keyValuePairs)) { $this->logger->info('Empty keyValuePairs, will not update.'); throw new OCSForbiddenException('Empty keyValuePairs, will not update.'); } - // Process owner transfer - if (sizeof($keyValuePairs) === 1 && key_exists('ownerId', $keyValuePairs)) { - $this->logger->debug('Updating owner: formId: {formId}, userId: {uid}', [ - 'formId' => $formId, - 'uid' => $keyValuePairs['ownerId'] - ]); + // Only allow the form owner to set/unset the "archived" state + $this->checkArchivePermission($form, $currentUserId, $keyValuePairs); - $user = $this->userManager->get($keyValuePairs['ownerId']); - if ($user == null) { - $this->logger->debug('Could not find new form owner'); - throw new OCSBadRequestException('Could not find new form owner'); - } - - // update form owner - $form->setOwnerId($keyValuePairs['ownerId']); + // Handle form locking/unlocking + if ($this->isLockingRequest($keyValuePairs)) { + return $this->handleFormLocking($form, $currentUserId); + } + if ($this->isUnlockingRequest($keyValuePairs)) { + return $this->handleFormUnlocking($form, $currentUserId); + } - // Update changed Columns in Db. - $this->formMapper->update($form); + // Lock form temporary + $this->formsService->obtainFormLock($form); - return new DataResponse($form->getOwnerId()); + // Handle owner transfer + if ($this->isOwnerTransferRequest($keyValuePairs)) { + return $this->handleOwnerTransfer($form, $formId, $currentUserId, $keyValuePairs); } - // Don't allow to change params id, hash, ownerId, created, lastUpdated, fileId - if ( - key_exists('id', $keyValuePairs) || key_exists('hash', $keyValuePairs) - || key_exists('ownerId', $keyValuePairs) || key_exists('created', $keyValuePairs) - || isset($keyValuePairs['fileId']) || key_exists('lastUpdated', $keyValuePairs) - ) { - $this->logger->info('Not allowed to update id, hash, ownerId, created, fileId or lastUpdated'); - throw new OCSForbiddenException('Not allowed to update id, hash, ownerId, created, fileId or lastUpdated'); - } + // Don't allow to change the following attributes + $this->checkForbiddenKeys($keyValuePairs); - // Do not allow changing showToAllUsers if disabled - if (isset($keyValuePairs['access'])) { - $showAll = $keyValuePairs['access']['showToAllUsers'] ?? false; - $permitAll = $keyValuePairs['access']['permitAllUsers'] ?? false; - if (($showAll && !$this->configService->getAllowShowToAll()) - || ($permitAll && !$this->configService->getAllowPermitAll())) { - $this->logger->info('Not allowed to update showToAllUsers or permitAllUsers'); - throw new OCSForbiddenException(); - } - } + // Don't allow to change fileId + $this->checkFileIdUpdate($keyValuePairs); + + // Do not allow changing showToAllUsers or permitAllUsers if disabled + $this->checkAccessUpdate($keyValuePairs); // Process file linking if (isset($keyValuePairs['path']) && isset($keyValuePairs['fileFormat'])) { $file = $this->submissionService->writeFileToCloud($form, $keyValuePairs['path'], $keyValuePairs['fileFormat']); - $form->setFileId($file->getId()); $form->setFileFormat($keyValuePairs['fileFormat']); } @@ -378,7 +352,7 @@ public function deleteForm(int $formId): DataResponse { 'formId' => $formId, ]); - $form = $this->getFormIfAllowed($formId); + $form = $this->formsService->getFormIfAllowed($formId); $this->formMapper->deleteForm($form); return new DataResponse($formId); @@ -488,7 +462,9 @@ public function getQuestion(int $formId, int $questionId): DataResponse { #[BruteForceProtection(action: 'form')] #[ApiRoute(verb: 'POST', url: '/api/v3/forms/{formId}/questions')] public function newQuestion(int $formId, ?string $type = null, string $text = '', ?int $fromId = null): DataResponse { - $form = $this->getFormIfAllowed($formId); + $form = $this->formsService->getFormIfAllowed($formId, Constants::PERMISSION_EDIT); + $this->formsService->obtainFormLock($form); + if ($this->formsService->isFormArchived($form)) { $this->logger->debug('This form is archived and can not be modified'); throw new OCSForbiddenException('This form is archived and can not be modified'); @@ -619,7 +595,9 @@ public function updateQuestion(int $formId, int $questionId, array $keyValuePair // Make sure we query the form first to check the user has permissions // So the user does not get information about "questions" if they do not even have permissions to the form - $form = $this->getFormIfAllowed($formId); + $form = $this->formsService->getFormIfAllowed($formId, Constants::PERMISSION_EDIT); + $this->formsService->obtainFormLock($form); + if ($this->formsService->isFormArchived($form)) { $this->logger->debug('This form is archived and can not be modified'); throw new OCSForbiddenException('This form is archived and can not be modified'); @@ -693,7 +671,9 @@ public function deleteQuestion(int $formId, int $questionId): DataResponse { ]); - $form = $this->getFormIfAllowed($formId); + $form = $this->formsService->getFormIfAllowed($formId, Constants::PERMISSION_EDIT); + $this->formsService->obtainFormLock($form); + if ($this->formsService->isFormArchived($form)) { $this->logger->debug('This form is archived and can not be modified'); throw new OCSForbiddenException('This form is archived and can not be modified'); @@ -759,7 +739,9 @@ public function reorderQuestions(int $formId, array $newOrder): DataResponse { 'newOrder' => $newOrder ]); - $form = $this->getFormIfAllowed($formId); + $form = $this->formsService->getFormIfAllowed($formId, Constants::PERMISSION_EDIT); + $this->formsService->obtainFormLock($form); + if ($this->formsService->isFormArchived($form)) { $this->logger->debug('This form is archived and can not be modified'); throw new OCSForbiddenException('This form is archived and can not be modified'); @@ -858,7 +840,9 @@ public function newOption(int $formId, int $questionId, array $optionTexts): Dat 'text' => $optionTexts, ]); - $form = $this->getFormIfAllowed($formId); + $form = $this->formsService->getFormIfAllowed($formId, Constants::PERMISSION_EDIT); + $this->formsService->obtainFormLock($form); + if ($this->formsService->isFormArchived($form)) { $this->logger->debug('This form is archived and can not be modified'); throw new OCSForbiddenException('This form is archived and can not be modified'); @@ -940,7 +924,9 @@ public function updateOption(int $formId, int $questionId, int $optionId, array 'keyValuePairs' => $keyValuePairs ]); - $form = $this->getFormIfAllowed($formId); + $form = $this->formsService->getFormIfAllowed($formId, Constants::PERMISSION_EDIT); + $this->formsService->obtainFormLock($form); + if ($this->formsService->isFormArchived($form)) { $this->logger->debug('This form is archived and can not be modified'); throw new OCSForbiddenException('This form is archived and can not be modified'); @@ -1007,7 +993,9 @@ public function deleteOption(int $formId, int $questionId, int $optionId): DataR 'optionId' => $optionId ]); - $form = $this->getFormIfAllowed($formId); + $form = $this->formsService->getFormIfAllowed($formId, Constants::PERMISSION_EDIT); + $this->formsService->obtainFormLock($form); + if ($this->formsService->isFormArchived($form)) { $this->logger->debug('This form is archived and can not be modified'); throw new OCSForbiddenException('This form is archived and can not be modified'); @@ -1063,7 +1051,9 @@ public function deleteOption(int $formId, int $questionId, int $optionId): DataR #[BruteForceProtection(action: 'form')] #[ApiRoute(verb: 'PATCH', url: '/api/v3/forms/{formId}/questions/{questionId}/options')] public function reorderOptions(int $formId, int $questionId, array $newOrder) { - $form = $this->getFormIfAllowed($formId); + $form = $this->formsService->getFormIfAllowed($formId, Constants::PERMISSION_EDIT); + $this->formsService->obtainFormLock($form); + if ($this->formsService->isFormArchived($form)) { $this->logger->debug('This form is archived and can not be modified'); throw new OCSForbiddenException('This form is archived and can not be modified'); @@ -1164,7 +1154,7 @@ public function reorderOptions(int $formId, int $questionId, array $newOrder) { #[BruteForceProtection(action: 'form')] #[ApiRoute(verb: 'GET', url: '/api/v3/forms/{formId}/submissions')] public function getSubmissions(int $formId, ?string $query = null, ?int $limit = null, int $offset = 0, ?string $fileFormat = null): DataResponse|DataDownloadResponse { - $form = $this->getFormIfAllowed($formId, Constants::PERMISSION_RESULTS); + $form = $this->formsService->getFormIfAllowed($formId, Constants::PERMISSION_RESULTS); if ($fileFormat !== null) { $submissionsData = $this->submissionService->getSubmissionsData($form, $fileFormat); @@ -1237,7 +1227,7 @@ public function getSubmissions(int $formId, ?string $query = null, ?int $limit = #[BruteForceProtection(action: 'form')] #[ApiRoute(verb: 'GET', url: '/api/v3/forms/{formId}/submissions/{submissionId}')] public function getSubmission(int $formId, int $submissionId): DataResponse|DataDownloadResponse { - $form = $this->getFormIfAllowed($formId, Constants::PERMISSION_RESULTS); + $form = $this->formsService->getFormIfAllowed($formId, Constants::PERMISSION_RESULTS); $submission = $this->submissionService->getSubmission($submissionId); if ($submission === null) { @@ -1282,7 +1272,7 @@ public function getSubmission(int $formId, int $submissionId): DataResponse|Data #[BruteForceProtection(action: 'form')] #[ApiRoute(verb: 'DELETE', url: '/api/v3/forms/{formId}/submissions')] public function deleteAllSubmissions(int $formId): DataResponse { - $form = $this->getFormIfAllowed($formId, Constants::PERMISSION_RESULTS_DELETE); + $form = $this->formsService->getFormIfAllowed($formId, Constants::PERMISSION_RESULTS_DELETE); // Delete all submissions (incl. Answers) $this->submissionMapper->deleteByForm($formId); @@ -1319,7 +1309,7 @@ public function newSubmission(int $formId, array $answers, string $shareHash = ' 'shareHash' => $shareHash, ]); - $form = $this->loadFormForSubmission($formId, $shareHash); + $form = $this->formsService->loadFormForSubmission($formId, $shareHash); $questions = $this->formsService->getQuestions($formId); try { @@ -1405,7 +1395,7 @@ public function updateSubmission(int $formId, int $submissionId, array $answers) ]); // submissions can't be updated on public shares, so passing empty shareHash - $form = $this->loadFormForSubmission($formId, ''); + $form = $this->formsService->loadFormForSubmission($formId, ''); if (!$form->getAllowEditSubmissions()) { throw new OCSBadRequestException('Can only update if allowEditSubmissions is set'); @@ -1480,7 +1470,7 @@ public function deleteSubmission(int $formId, int $submissionId): DataResponse { 'submissionId' => $submissionId, ]); - $form = $this->getFormIfAllowed($formId, Constants::PERMISSION_RESULTS_DELETE); + $form = $this->formsService->getFormIfAllowed($formId, Constants::PERMISSION_RESULTS_DELETE); try { $submission = $this->submissionMapper->findById($submissionId); } catch (IMapperException $e) { @@ -1524,7 +1514,7 @@ public function deleteSubmission(int $formId, int $submissionId): DataResponse { #[BruteForceProtection(action: 'form')] #[ApiRoute(verb: 'POST', url: '/api/v3/forms/{formId}/submissions/export')] public function exportSubmissionsToCloud(int $formId, string $path, string $fileFormat = Constants::DEFAULT_FILE_FORMAT) { - $form = $this->getFormIfAllowed($formId, Constants::PERMISSION_RESULTS); + $form = $this->formsService->getFormIfAllowed($formId, Constants::PERMISSION_RESULTS); $file = $this->submissionService->writeFileToCloud($form, $path, $fileFormat); return new DataResponse($file->getName()); @@ -1570,7 +1560,7 @@ public function uploadFiles(int $formId, int $questionId, string $shareHash = '' throw new OCSBadRequestException('No files provided'); } - $form = $this->loadFormForSubmission($formId, $shareHash); + $form = $this->formsService->loadFormForSubmission($formId, $shareHash); if (!$this->formsService->canSubmit($form)) { throw new OCSForbiddenException('Already submitted'); @@ -1738,84 +1728,148 @@ private function storeAnswersForQuestion(Form $form, $submissionId, array $quest } } - private function loadFormForSubmission(int $formId, string $shareHash): Form { - try { - $form = $this->formMapper->findById($formId); - } catch (IMapperException $e) { - $this->logger->debug('Could not find form'); - throw new NoSuchFormException('Could not find form'); + /** + * Throws if forbidden keys are present in update + */ + private function checkForbiddenKeys(array $keyValuePairs): void { + $forbiddenKeys = [ + 'id', 'hash', 'ownerId', 'created', 'lastUpdated', 'lockedBy', 'lockedUntil' + ]; + foreach ($forbiddenKeys as $key) { + if (array_key_exists($key, $keyValuePairs)) { + $this->logger->info("Not allowed to update {$key}"); + throw new OCSForbiddenException("Not allowed to update {$key}"); + } } + } - // 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 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)) { - throw new NoSuchFormException('Not allowed to access this form', Http::STATUS_FORBIDDEN); + /** + * Throws if fileId is present in update + */ + private function checkFileIdUpdate(array $keyValuePairs): void { + if (isset($keyValuePairs['fileId'])) { + $this->logger->info('Not allowed to update fileId'); + throw new OCSForbiddenException('Not allowed to update fileId'); + } + } + + /** + * Throws if access keys are being updated when not allowed + */ + private function checkAccessUpdate(array $keyValuePairs): void { + if (isset($keyValuePairs['access'])) { + $showAll = $keyValuePairs['access']['showToAllUsers'] ?? false; + $permitAll = $keyValuePairs['access']['permitAllUsers'] ?? false; + if (($showAll && !$this->configService->getAllowShowToAll()) + || ($permitAll && !$this->configService->getAllowPermitAll())) { + $this->logger->info('Not allowed to update showToAllUsers or permitAllUsers'); + throw new OCSForbiddenException(); } } + } - // Not allowed if form has expired. - if ($this->formsService->hasFormExpired($form)) { - throw new OCSForbiddenException('This form is no longer taking answers'); + /** + * Checks if the current user is allowed to archive/unarchive the form + */ + private function checkArchivePermission(Form $form, string $currentUserId, array $keyValuePairs): void { + $isArchived = $this->formsService->isFormArchived($form); + $owner = $currentUserId === $form->getOwnerId(); + $onlyState = sizeof($keyValuePairs) === 1 && key_exists('state', $keyValuePairs); + + // Only check if the request is trying to change the archived state + if ($onlyState && $keyValuePairs['state'] === Constants::FORM_STATE_ARCHIVED) { + // Trying to archive + if (!$owner || $isArchived) { + $this->logger->debug('Only the form owner can archive the form, and only if it is not already archived'); + throw new OCSForbiddenException('Only the form owner can archive the form, and only if it is not already archived'); + } + } elseif ($onlyState && $keyValuePairs['state'] === Constants::FORM_STATE_CLOSED) { + // Trying to unarchive + if (!$owner || !$isArchived) { + $this->logger->debug('Only the form owner can unarchive the form, and only if it is currently archived'); + throw new OCSForbiddenException('Only the form owner can unarchive the form, and only if it is currently archived'); + } } + // All other updates are allowed (including updates that do not touch the state) + } + + private function isLockingRequest(array $keyValuePairs): bool { + return sizeof($keyValuePairs) === 1 + && array_key_exists('lockedUntil', $keyValuePairs) + && $keyValuePairs['lockedUntil'] === 0; + } + + private function isUnlockingRequest(array $keyValuePairs): bool { + return sizeof($keyValuePairs) === 1 + && array_key_exists('lockedUntil', $keyValuePairs) + && is_null($keyValuePairs['lockedUntil']); + } - return $form; + private function isOwnerTransferRequest(array $keyValuePairs): bool { + return sizeof($keyValuePairs) === 1 && key_exists('ownerId', $keyValuePairs); } /** - * Helper that retrieves a form if the current user is allowed to edit it - * This throws an exception in case either the form is not found or permissions are missing. - * @param int $formId The form ID to retrieve - * @throws NoSuchFormException If the form was not found or the current user has no permission to edit + * @return DataResponse */ - private function getFormIfAllowed(int $formId, string $permissions = 'all'): Form { - try { - $form = $this->formMapper->findById($formId); - } catch (IMapperException $e) { - $this->logger->debug('Could not find form'); - throw new NoSuchFormException('Could not find form'); + private function handleFormLocking(Form $form, string $currentUserId): DataResponse { + if ($currentUserId !== $form->getOwnerId() || ($form->getLockedBy() !== null && $currentUserId !== $form->getLockedBy())) { + $this->logger->debug('Only the form owner can lock the form permanently'); + throw new OCSForbiddenException('Only the form owner can lock the form permanently'); } + if ( + $form->getLockedBy() !== null + && $form->getLockedBy() !== $currentUserId + && $form->getLockedUntil() >= time() + ) { + $this->logger->debug('Form is currently locked by another user.'); + throw new OCSForbiddenException('Form is currently locked by another user.'); + } + if ($form->getLockedUntil() === 0) { + $this->logger->debug('Form is already locked completely.'); + throw new OCSBadRequestException('Form is already locked completely.'); + } + $form->setLockedBy($form->getOwnerId()); + $form->setLockedUntil(0); + $this->formMapper->update($form); + return new DataResponse($form->getId()); + } - switch ($permissions) { - case Constants::PERMISSION_SUBMIT: - if (!$this->formsService->hasUserAccess($form)) { - $this->logger->debug('User has no permissions to get this form'); - throw new NoSuchFormException('User has no permissions to get this form', Http::STATUS_FORBIDDEN); - } - break; - case Constants::PERMISSION_RESULTS: - if (!$this->formsService->canSeeResults($form)) { - $this->logger->debug('The current user has no permission to get the results for this form'); - throw new NoSuchFormException('The current user has no permission to get the results for this form', Http::STATUS_FORBIDDEN); - } - break; - case Constants::PERMISSION_RESULTS_DELETE: - if (!$this->formsService->canDeleteResults($form)) { - $this->logger->debug('This form is not owned by the current user and user has no `results_delete` permission'); - throw new NoSuchFormException('This form is not owned by the current user and user has no `results_delete` permission', Http::STATUS_FORBIDDEN); - } - break; - default: - // By default we request full permissions - if ($form->getOwnerId() !== $this->currentUser->getUID()) { - $this->logger->debug('This form is not owned by the current user'); - throw new NoSuchFormException('This form is not owned by the current user', Http::STATUS_FORBIDDEN); - } - break; + /** + * @return DataResponse + */ + private function handleFormUnlocking(Form $form, string $currentUserId): DataResponse { + if ($currentUserId !== $form->getOwnerId() && $currentUserId !== $form->getLockedBy() && $form->getLockedUntil() !== 0) { + $this->logger->debug('Only the form owner or the user who obtained the lock can unlock the form'); + throw new OCSForbiddenException('Only the form owner or the user who obtained the lock can unlock the form'); + } + $form->setLockedBy(null); + $form->setLockedUntil(null); + $this->formMapper->update($form); + return new DataResponse($form->getId()); + } + + /** + * @return DataResponse + */ + private function handleOwnerTransfer(Form $form, int $formId, string $currentUserId, array $keyValuePairs): DataResponse { + if ($currentUserId !== $form->getOwnerId()) { + $this->logger->debug('Only the form owner can transfer ownership'); + throw new OCSForbiddenException('Only the form owner can transfer ownership'); } - return $form; + $this->logger->debug('Updating owner: formId: {formId}, userId: {uid}', [ + 'formId' => $formId, + 'uid' => $keyValuePairs['ownerId'] + ]); + $user = $this->userManager->get($keyValuePairs['ownerId']); + if ($user == null) { + $this->logger->debug('Could not find new form owner'); + throw new OCSBadRequestException('Could not find new form owner'); + } + $form->setOwnerId($keyValuePairs['ownerId']); + $form->setLockedBy(null); + $form->setLockedUntil(null); + $this->formMapper->update($form); + return new DataResponse($form->getOwnerId()); } } diff --git a/lib/Controller/ShareApiController.php b/lib/Controller/ShareApiController.php index 067c171a1..21b15753f 100644 --- a/lib/Controller/ShareApiController.php +++ b/lib/Controller/ShareApiController.php @@ -104,6 +104,12 @@ public function newShare(int $formId, int $shareType, string $shareWith = '', ar 'permissions' => $permissions, ]); + $form = $this->formsService->getFormIfAllowed($formId); + if ($this->formsService->isFormArchived($form)) { + $this->logger->debug('This form is archived and can not be modified'); + throw new OCSForbiddenException('This form is archived and can not be modified'); + } + // Only accept usable shareTypes if (array_search($shareType, Constants::SHARE_TYPES_USED) === false) { $this->logger->debug('Invalid shareType'); @@ -116,24 +122,6 @@ public function newShare(int $formId, int $shareType, string $shareWith = '', ar throw new OCSForbiddenException('Link share not allowed.'); } - try { - $form = $this->formMapper->findById($formId); - } catch (IMapperException $e) { - $this->logger->debug('Could not find form', ['exception' => $e]); - throw new OCSNotFoundException('Could not find form'); - } - - if ($this->formsService->isFormArchived($form)) { - $this->logger->debug('This form is archived and can not be modified'); - throw new OCSForbiddenException('This form is archived and can not be modified'); - } - - // 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('This form is not owned by the current user'); - } - if (!$this->validatePermissions($permissions, $shareType)) { throw new OCSBadRequestException('Invalid permission given'); } @@ -194,6 +182,8 @@ public function newShare(int $formId, int $shareType, string $shareWith = '', ar throw new OCSBadRequestException('Unknown shareType.'); } + $this->formsService->obtainFormLock($form); + $share = new Share(); $share->setFormId($formId); $share->setShareType($shareType); @@ -240,29 +230,24 @@ public function updateShare(int $formId, int $shareId, array $keyValuePairs): Da 'keyValuePairs' => $keyValuePairs ]); + $form = $this->formsService->getFormIfAllowed($formId); + if ($this->formsService->isFormArchived($form)) { + $this->logger->debug('This form is archived and can not be modified'); + throw new OCSForbiddenException('This form is archived and can not be modified'); + } + try { $formShare = $this->shareMapper->findById($shareId); - $form = $this->formMapper->findById($formId); } catch (IMapperException $e) { $this->logger->debug('Could not find share', ['exception' => $e]); throw new OCSNotFoundException('Could not find share'); } - if ($this->formsService->isFormArchived($form)) { - $this->logger->debug('This form is archived and can not be modified'); - throw new OCSForbiddenException('This form is archived and can not be modified'); - } - if ($formId !== $formShare->getFormId()) { $this->logger->debug('This share doesn\'t belong to the given Form'); throw new OCSBadRequestException('Share doesn\'t belong to given Form'); } - if ($form->getOwnerId() !== $this->currentUser->getUID()) { - $this->logger->debug('This form is not owned by the current user'); - throw new OCSForbiddenException('This form is not owned by the current user'); - } - // Don't allow empty array if (sizeof($keyValuePairs) === 0) { $this->logger->info('Empty keyValuePairs, will not update.'); @@ -279,6 +264,8 @@ public function updateShare(int $formId, int $shareId, array $keyValuePairs): Da throw new OCSBadRequestException('Invalid permission given'); } + $this->formsService->obtainFormLock($form); + $formShare->setPermissions($keyValuePairs['permissions']); $formShare = $this->shareMapper->update($formShare); @@ -338,28 +325,25 @@ public function deleteShare(int $formId, int $shareId): DataResponse { 'shareId' => $shareId, ]); + $form = $this->formsService->getFormIfAllowed($formId); + if ($this->formsService->isFormArchived($form)) { + $this->logger->debug('This form is archived and can not be modified'); + throw new OCSForbiddenException('This form is archived and can not be modified'); + } + try { $share = $this->shareMapper->findById($shareId); - $form = $this->formMapper->findById($formId); } catch (IMapperException $e) { $this->logger->debug('Could not find share', ['exception' => $e]); throw new OCSNotFoundException('Could not find share'); } - if ($this->formsService->isFormArchived($form)) { - $this->logger->debug('This form is archived and can not be modified'); - throw new OCSForbiddenException('This form is archived and can not be modified'); - } - if ($formId !== $share->getFormId()) { $this->logger->debug('This share doesn\'t belong to the given Form'); throw new OCSBadRequestException('Share doesn\'t belong to given Form'); } - if ($form->getOwnerId() !== $this->currentUser->getUID()) { - $this->logger->debug('This form is not owned by the current user'); - throw new OCSForbiddenException('This form is not owned by the current user'); - } + $this->formsService->obtainFormLock($form); $this->shareMapper->delete($share); $this->formMapper->update($form); diff --git a/lib/Db/Form.php b/lib/Db/Form.php index a49181be6..fe1637eda 100644 --- a/lib/Db/Form.php +++ b/lib/Db/Form.php @@ -47,6 +47,10 @@ * @psalm-method 0|1|2 getState() * @method void setState(int|null $value) * @psalm-method void setState(0|1|2|null $value) + * @method string getLockedBy() + * @method void setLockedBy(string|null $value) + * @method int getLockedUntil() + * @method void setLockedUntil(int|null $value) */ class Form extends Entity { protected $hash; @@ -65,6 +69,8 @@ class Form extends Entity { protected $submissionMessage; protected $lastUpdated; protected $state; + protected $lockedBy; + protected $lockedUntil; /** * Form constructor. @@ -78,6 +84,8 @@ public function __construct() { $this->addType('showExpiration', 'boolean'); $this->addType('lastUpdated', 'integer'); $this->addType('state', 'integer'); + $this->addType('lockedBy', 'string'); + $this->addType('lockedUntil', 'integer'); } // JSON-Decoding of access-column. @@ -149,6 +157,8 @@ public function setAccess(array $access): void { * lastUpdated: int, * submissionMessage: ?string, * state: 0|1|2, + * lockedBy: ?string, + * lockedUntil: ?int, * } */ public function read() { @@ -170,6 +180,8 @@ public function read() { 'lastUpdated' => (int)$this->getLastUpdated(), 'submissionMessage' => $this->getSubmissionMessage(), 'state' => $this->getState(), + 'lockedBy' => $this->getLockedBy(), + 'lockedUntil' => $this->getLockedUntil(), ]; } } diff --git a/lib/Migration/Version050200Date20250512004000.php b/lib/Migration/Version050200Date20250512004000.php new file mode 100644 index 000000000..d8cbf762c --- /dev/null +++ b/lib/Migration/Version050200Date20250512004000.php @@ -0,0 +1,48 @@ +getTable('forms_v2_forms'); + + if (!$table->hasColumn('locked_by')) { + $table->addColumn('locked_by', Types::STRING, [ + 'notnull' => false, + 'default' => null, + ]); + } + + if (!$table->hascolumn('locked_until')) { + $table->addColumn('locked_until', Types::INTEGER, [ + 'notnull' => false, + 'default' => null, + 'comment' => 'unix-timestamp', + ]); + } + + return $schema; + } +} diff --git a/lib/ResponseDefinitions.php b/lib/ResponseDefinitions.php index 05e8e08cc..b4fc22e70 100644 --- a/lib/ResponseDefinitions.php +++ b/lib/ResponseDefinitions.php @@ -104,7 +104,9 @@ * expires: int, * permissions: list, * partial: true, - * state: int + * state: int, + * lockedBy: ?string, + * lockedUntil: ?int, * } * * @psalm-type FormsForm = array{ @@ -128,6 +130,8 @@ * permissions: list, * questions: list, * state: 0|1|2, + * lockedBy: ?string, + * lockedUntil: ?int, * shares: list, * submissionCount?: int, * submissionMessage: ?string, diff --git a/lib/Service/FormsService.php b/lib/Service/FormsService.php index f25837ad2..9a4c2d546 100644 --- a/lib/Service/FormsService.php +++ b/lib/Service/FormsService.php @@ -19,9 +19,12 @@ use OCA\Forms\Db\Submission; use OCA\Forms\Db\SubmissionMapper; use OCA\Forms\Events\FormSubmittedEvent; +use OCA\Forms\Exception\NoSuchFormException; use OCA\Forms\ResponseDefinitions; use OCP\AppFramework\Db\DoesNotExistException; use OCP\AppFramework\Db\IMapperException; +use OCP\AppFramework\Http; +use OCP\AppFramework\OCS\OCSForbiddenException; use OCP\EventDispatcher\IEventDispatcher; use OCP\Files\IRootFolder; use OCP\Files\NotFoundException; @@ -62,8 +65,8 @@ public function __construct( private CirclesService $circlesService, private IRootFolder $rootFolder, private IL10N $l10n, - private IEventDispatcher $eventDispatcher, private LoggerInterface $logger, + private IEventDispatcher $eventDispatcher, ) { $this->currentUser = $userSession->getUser(); } @@ -243,6 +246,8 @@ public function getPartialFormArray(Form $form): array { 'permissions' => $this->getPermissions($form), 'partial' => true, 'state' => $form->getState(), + 'lockedBy' => $form->getLockedBy(), + 'lockedUntil' => $form->getLockedUntil(), ]; // Append submissionCount if currentUser has permissions to see results @@ -281,6 +286,112 @@ public function getPublicForm(Form $form): array { return $formData; } + /** + * Helper that retrieves a form if the current user is allowed to edit it + * This throws an exception in case either the form is not found or permissions are missing. + * @param int $formId The form ID to retrieve + * @throws NoSuchFormException If the form was not found or the current user has no permission to edit + */ + public function getFormIfAllowed(int $formId, string $permissions = 'all'): Form { + try { + $form = $this->formMapper->findById($formId); + } catch (IMapperException $e) { + $this->logger->debug('Could not find form'); + throw new NoSuchFormException('Could not find form'); + } + + switch ($permissions) { + case Constants::PERMISSION_SUBMIT: + if (!$this->hasUserAccess($form)) { + $this->logger->debug('User has no permissions to get this form'); + throw new NoSuchFormException('User has no permissions to get this form', Http::STATUS_FORBIDDEN); + } + break; + case Constants::PERMISSION_RESULTS: + if (!$this->canSeeResults($form)) { + $this->logger->debug('The current user has no permission to get the results for this form'); + throw new NoSuchFormException('The current user has no permission to get the results for this form', Http::STATUS_FORBIDDEN); + } + break; + case Constants::PERMISSION_RESULTS_DELETE: + if (!$this->canDeleteResults($form)) { + $this->logger->debug('This form is not owned by the current user and user has no `results_delete` permission'); + throw new NoSuchFormException('This form is not owned by the current user and user has no `results_delete` permission', Http::STATUS_FORBIDDEN); + } + break; + case Constants::PERMISSION_EDIT: + if (!$this->canEditForm($form)) { + $this->logger->debug('This form is not owned by the current user and user has no `edit` permission'); + throw new NoSuchFormException('This form is not owned by the current user and user has no `edit` permission', Http::STATUS_FORBIDDEN); + } + break; + default: + // By default we request full permissions + if ($form->getOwnerId() !== $this->currentUser->getUID()) { + $this->logger->debug('This form is not owned by the current user'); + throw new NoSuchFormException('This form is not owned by the current user', Http::STATUS_FORBIDDEN); + } + break; + } + return $form; + } + + public function loadFormForSubmission(int $formId, string $shareHash): Form { + try { + $form = $this->formMapper->findById($formId); + } catch (IMapperException $e) { + $this->logger->debug('Could not find form'); + throw new NoSuchFormException('Could not find 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 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->hasUserAccess($form)) { + throw new NoSuchFormException('Not allowed to access this form', Http::STATUS_FORBIDDEN); + } + } + + // Not allowed if form has expired. + if ($this->hasFormExpired($form)) { + throw new OCSForbiddenException('This form is no longer taking answers'); + } + + return $form; + } + + /** + * Locks the given form for the current user for a duration of 15 minutes. + * + * @param Form $form The form instance to lock. + */ + public function obtainFormLock(Form $form): void { + // Only lock if not locked or locked by current user, or lock has expired + if ( + $form->getLockedBy() !== null + && $form->getLockedBy() !== $this->currentUser->getUID() + && ($form->getLockedUntil() >= time() || $form->getLockedUntil() === 0) + ) { + throw new OCSForbiddenException('Form is currently locked by another user.'); + } + + $form->setLockedBy($this->currentUser->getUID()); + $form->setLockedUntil(time() + 15 * 60); + } + /** * Get current users permissions on a form * diff --git a/openapi.json b/openapi.json index be4bcbb84..3ed422c89 100644 --- a/openapi.json +++ b/openapi.json @@ -111,6 +111,8 @@ "permissions", "questions", "state", + "lockedBy", + "lockedUntil", "shares", "submissionMessage" ], @@ -195,6 +197,15 @@ 2 ] }, + "lockedBy": { + "type": "string", + "nullable": true + }, + "lockedUntil": { + "type": "integer", + "format": "int64", + "nullable": true + }, "shares": { "type": "array", "items": { @@ -291,7 +302,9 @@ "expires", "permissions", "partial", - "state" + "state", + "lockedBy", + "lockedUntil" ], "properties": { "id": { @@ -323,6 +336,15 @@ "state": { "type": "integer", "format": "int64" + }, + "lockedBy": { + "type": "string", + "nullable": true + }, + "lockedUntil": { + "type": "integer", + "format": "int64", + "nullable": true } } }, diff --git a/src/components/AppNavigationForm.vue b/src/components/AppNavigationForm.vue index 0862bbd93..02c196593 100644 --- a/src/components/AppNavigationForm.vue +++ b/src/components/AppNavigationForm.vue @@ -25,10 +25,13 @@ -