Skip to content

Commit d5b2f95

Browse files
authored
Merge pull request #2737 from nextcloud/feat/shareEditPermission
feat: add form locking mechanism and share `edit` permission
2 parents 48d690e + aa8eca7 commit d5b2f95

35 files changed

Lines changed: 954 additions & 551 deletions

docs/API_v3.md

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,9 @@ Returns condensed objects of all Forms beeing owned by the authenticated user.
9292
"submit"
9393
],
9494
"partial": true,
95-
"state": 0
95+
"state": 0,
96+
"lockedBy": null,
97+
"lockedUntil": null
9698
},
9799
{
98100
"id": 3,
@@ -105,7 +107,9 @@ Returns condensed objects of all Forms beeing owned by the authenticated user.
105107
"submit"
106108
],
107109
"partial": true,
108-
"state": 0
110+
"state": 0,
111+
"lockedBy": "someUser"
112+
"lockedUntil": 123456789
109113
}
110114
]
111115
```
@@ -169,6 +173,8 @@ Returns the full-depth object of the requested form (without submissions).
169173
"showExpiration": false,
170174
"canSubmit": true,
171175
"state": 0,
176+
"lockedBy": null,
177+
"lockedUntil": null,
172178
"permissions": [
173179
"edit",
174180
"results",

docs/DataStructure.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ This document describes the Object-Structure, that is used within the Forms App
2626
| expires | unix-timestamp | | When the form should expire. Timestamp `0` indicates _never_ |
2727
| isAnonymous | Boolean | | If Answers will be stored anonymously |
2828
| state | Integer | [Form state](#form-state) | The state of the form |
29+
| lockedBy | String | | The user ID for who has exclusive edit access at the moment |
30+
| lockedUntil | unix timestamp | | When the form lock will expire |
2931
| submitMultiple | Boolean | | If users are allowed to submit multiple times to the form |
3032
| allowEditSubmissions | Boolean | | If users are allowed to edit or delete their response |
3133
| 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
5961
],
6062
"questions": [],
6163
"state": 0,
64+
"lockedBy": null,
65+
"lockedUntil": null,
6266
"shares": []
6367
"submissions": [],
6468
"submissionCount": 0,

img/lock_open.svg

Lines changed: 1 addition & 0 deletions
Loading

lib/Controller/ApiController.php

Lines changed: 190 additions & 136 deletions
Large diffs are not rendered by default.

lib/Controller/ShareApiController.php

Lines changed: 23 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,12 @@ public function newShare(int $formId, int $shareType, string $shareWith = '', ar
104104
'permissions' => $permissions,
105105
]);
106106

107+
$form = $this->formsService->getFormIfAllowed($formId);
108+
if ($this->formsService->isFormArchived($form)) {
109+
$this->logger->debug('This form is archived and can not be modified');
110+
throw new OCSForbiddenException('This form is archived and can not be modified');
111+
}
112+
107113
// Only accept usable shareTypes
108114
if (array_search($shareType, Constants::SHARE_TYPES_USED) === false) {
109115
$this->logger->debug('Invalid shareType');
@@ -116,24 +122,6 @@ public function newShare(int $formId, int $shareType, string $shareWith = '', ar
116122
throw new OCSForbiddenException('Link share not allowed.');
117123
}
118124

119-
try {
120-
$form = $this->formMapper->findById($formId);
121-
} catch (IMapperException $e) {
122-
$this->logger->debug('Could not find form', ['exception' => $e]);
123-
throw new OCSNotFoundException('Could not find form');
124-
}
125-
126-
if ($this->formsService->isFormArchived($form)) {
127-
$this->logger->debug('This form is archived and can not be modified');
128-
throw new OCSForbiddenException('This form is archived and can not be modified');
129-
}
130-
131-
// Check for permission to share form
132-
if ($form->getOwnerId() !== $this->currentUser->getUID()) {
133-
$this->logger->debug('This form is not owned by the current user');
134-
throw new OCSForbiddenException('This form is not owned by the current user');
135-
}
136-
137125
if (!$this->validatePermissions($permissions, $shareType)) {
138126
throw new OCSBadRequestException('Invalid permission given');
139127
}
@@ -194,6 +182,8 @@ public function newShare(int $formId, int $shareType, string $shareWith = '', ar
194182
throw new OCSBadRequestException('Unknown shareType.');
195183
}
196184

185+
$this->formsService->obtainFormLock($form);
186+
197187
$share = new Share();
198188
$share->setFormId($formId);
199189
$share->setShareType($shareType);
@@ -240,29 +230,24 @@ public function updateShare(int $formId, int $shareId, array $keyValuePairs): Da
240230
'keyValuePairs' => $keyValuePairs
241231
]);
242232

233+
$form = $this->formsService->getFormIfAllowed($formId);
234+
if ($this->formsService->isFormArchived($form)) {
235+
$this->logger->debug('This form is archived and can not be modified');
236+
throw new OCSForbiddenException('This form is archived and can not be modified');
237+
}
238+
243239
try {
244240
$formShare = $this->shareMapper->findById($shareId);
245-
$form = $this->formMapper->findById($formId);
246241
} catch (IMapperException $e) {
247242
$this->logger->debug('Could not find share', ['exception' => $e]);
248243
throw new OCSNotFoundException('Could not find share');
249244
}
250245

251-
if ($this->formsService->isFormArchived($form)) {
252-
$this->logger->debug('This form is archived and can not be modified');
253-
throw new OCSForbiddenException('This form is archived and can not be modified');
254-
}
255-
256246
if ($formId !== $formShare->getFormId()) {
257247
$this->logger->debug('This share doesn\'t belong to the given Form');
258248
throw new OCSBadRequestException('Share doesn\'t belong to given Form');
259249
}
260250

261-
if ($form->getOwnerId() !== $this->currentUser->getUID()) {
262-
$this->logger->debug('This form is not owned by the current user');
263-
throw new OCSForbiddenException('This form is not owned by the current user');
264-
}
265-
266251
// Don't allow empty array
267252
if (sizeof($keyValuePairs) === 0) {
268253
$this->logger->info('Empty keyValuePairs, will not update.');
@@ -279,6 +264,8 @@ public function updateShare(int $formId, int $shareId, array $keyValuePairs): Da
279264
throw new OCSBadRequestException('Invalid permission given');
280265
}
281266

267+
$this->formsService->obtainFormLock($form);
268+
282269
$formShare->setPermissions($keyValuePairs['permissions']);
283270
$formShare = $this->shareMapper->update($formShare);
284271

@@ -338,28 +325,25 @@ public function deleteShare(int $formId, int $shareId): DataResponse {
338325
'shareId' => $shareId,
339326
]);
340327

328+
$form = $this->formsService->getFormIfAllowed($formId);
329+
if ($this->formsService->isFormArchived($form)) {
330+
$this->logger->debug('This form is archived and can not be modified');
331+
throw new OCSForbiddenException('This form is archived and can not be modified');
332+
}
333+
341334
try {
342335
$share = $this->shareMapper->findById($shareId);
343-
$form = $this->formMapper->findById($formId);
344336
} catch (IMapperException $e) {
345337
$this->logger->debug('Could not find share', ['exception' => $e]);
346338
throw new OCSNotFoundException('Could not find share');
347339
}
348340

349-
if ($this->formsService->isFormArchived($form)) {
350-
$this->logger->debug('This form is archived and can not be modified');
351-
throw new OCSForbiddenException('This form is archived and can not be modified');
352-
}
353-
354341
if ($formId !== $share->getFormId()) {
355342
$this->logger->debug('This share doesn\'t belong to the given Form');
356343
throw new OCSBadRequestException('Share doesn\'t belong to given Form');
357344
}
358345

359-
if ($form->getOwnerId() !== $this->currentUser->getUID()) {
360-
$this->logger->debug('This form is not owned by the current user');
361-
throw new OCSForbiddenException('This form is not owned by the current user');
362-
}
346+
$this->formsService->obtainFormLock($form);
363347

364348
$this->shareMapper->delete($share);
365349
$this->formMapper->update($form);

lib/Db/Form.php

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,10 @@
4747
* @psalm-method 0|1|2 getState()
4848
* @method void setState(int|null $value)
4949
* @psalm-method void setState(0|1|2|null $value)
50+
* @method string getLockedBy()
51+
* @method void setLockedBy(string|null $value)
52+
* @method int getLockedUntil()
53+
* @method void setLockedUntil(int|null $value)
5054
*/
5155
class Form extends Entity {
5256
protected $hash;
@@ -65,6 +69,8 @@ class Form extends Entity {
6569
protected $submissionMessage;
6670
protected $lastUpdated;
6771
protected $state;
72+
protected $lockedBy;
73+
protected $lockedUntil;
6874

6975
/**
7076
* Form constructor.
@@ -78,6 +84,8 @@ public function __construct() {
7884
$this->addType('showExpiration', 'boolean');
7985
$this->addType('lastUpdated', 'integer');
8086
$this->addType('state', 'integer');
87+
$this->addType('lockedBy', 'string');
88+
$this->addType('lockedUntil', 'integer');
8189
}
8290

8391
// JSON-Decoding of access-column.
@@ -149,6 +157,8 @@ public function setAccess(array $access): void {
149157
* lastUpdated: int,
150158
* submissionMessage: ?string,
151159
* state: 0|1|2,
160+
* lockedBy: ?string,
161+
* lockedUntil: ?int,
152162
* }
153163
*/
154164
public function read() {
@@ -170,6 +180,8 @@ public function read() {
170180
'lastUpdated' => (int)$this->getLastUpdated(),
171181
'submissionMessage' => $this->getSubmissionMessage(),
172182
'state' => $this->getState(),
183+
'lockedBy' => $this->getLockedBy(),
184+
'lockedUntil' => $this->getLockedUntil(),
173185
];
174186
}
175187
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
7+
* SPDX-License-Identifier: AGPL-3.0-or-later
8+
*/
9+
10+
namespace OCA\Forms\Migration;
11+
12+
use Closure;
13+
use OCP\DB\ISchemaWrapper;
14+
use OCP\DB\Types;
15+
use OCP\Migration\IOutput;
16+
use OCP\Migration\SimpleMigrationStep;
17+
18+
class Version050200Date20250512004000 extends SimpleMigrationStep {
19+
20+
/**
21+
* @param IOutput $output
22+
* @param Closure $schemaClosure The `\Closure` returns a `ISchemaWrapper`
23+
* @param array $options
24+
* @return null|ISchemaWrapper
25+
*/
26+
public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper {
27+
/** @var ISchemaWrapper $schema */
28+
$schema = $schemaClosure();
29+
$table = $schema->getTable('forms_v2_forms');
30+
31+
if (!$table->hasColumn('locked_by')) {
32+
$table->addColumn('locked_by', Types::STRING, [
33+
'notnull' => false,
34+
'default' => null,
35+
]);
36+
}
37+
38+
if (!$table->hascolumn('locked_until')) {
39+
$table->addColumn('locked_until', Types::INTEGER, [
40+
'notnull' => false,
41+
'default' => null,
42+
'comment' => 'unix-timestamp',
43+
]);
44+
}
45+
46+
return $schema;
47+
}
48+
}

lib/ResponseDefinitions.php

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,9 @@
104104
* expires: int,
105105
* permissions: list<FormsPermission>,
106106
* partial: true,
107-
* state: int
107+
* state: int,
108+
* lockedBy: ?string,
109+
* lockedUntil: ?int,
108110
* }
109111
*
110112
* @psalm-type FormsForm = array{
@@ -128,6 +130,8 @@
128130
* permissions: list<FormsPermission>,
129131
* questions: list<FormsQuestion>,
130132
* state: 0|1|2,
133+
* lockedBy: ?string,
134+
* lockedUntil: ?int,
131135
* shares: list<FormsShare>,
132136
* submissionCount?: int,
133137
* submissionMessage: ?string,

0 commit comments

Comments
 (0)