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 @@
{{ formSubtitle }}
-
+
@@ -38,7 +41,7 @@
{{ t('forms', 'Edit form') }}
@@ -47,6 +50,7 @@
{{ t('forms', 'Share form') }}
{{ t('forms', 'Copy form') }}
-
+
@@ -78,8 +83,9 @@
}}
@@ -100,6 +106,7 @@