Skip to content

Commit be327ac

Browse files
Add public share token editing
Signed-off-by: Alexander Rebello <me@alexander-rebello.de>
1 parent 557abb5 commit be327ac

4 files changed

Lines changed: 274 additions & 1 deletion

File tree

docs/API_v3.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -653,6 +653,30 @@ Update a single or all properties of an option-object
653653
"data": 5
654654
```
655655

656+
### Update a Public Share Token
657+
658+
- Endpoint: `/api/v3/forms/{formId}/shares/{shareId}/token`
659+
- Method: `PATCH`
660+
- Url-Parameters:
661+
| Parameter | Type | Description |
662+
|-----------|---------|-------------|
663+
| _formId_ | Integer | ID of the form containing the share |
664+
| _shareId_ | Integer | ID of the public link share to update |
665+
- Parameters:
666+
| Parameter | Type | Description |
667+
|-----------|---------|-------------|
668+
| _token_ | String | New token for the public share link |
669+
- Restrictions:
670+
- Only available when the admin setting _allowCustomPublicShareTokens_ is enabled.
671+
- Only link shares can be updated.
672+
- Token must be unique among link shares and only contain alphanumeric characters.
673+
- Token length must be between 8 and 256 characters.
674+
- Response: **Status-Code OK**, as well as the id of the updated share.
675+
676+
```
677+
"data": 5
678+
```
679+
656680
## Submission Endpoints
657681

658682
### Get Form Submissions

lib/Controller/PageController.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
#[OpenAPI(scope: OpenAPI::SCOPE_IGNORE)]
4141
class PageController extends Controller {
4242
private const TEMPLATE_MAIN = 'main';
43+
private const PUBLIC_SHARE_HASH_REQUIREMENT = '[a-zA-Z0-9]{8,256}';
4344

4445
public function __construct(
4546
string $appName,
@@ -145,7 +146,7 @@ public function internalLinkView(string $hash): Response {
145146
#[NoAdminRequired()]
146147
#[NoCSRFRequired()]
147148
#[PublicPage()]
148-
#[FrontpageRoute(verb: 'GET', url: '/s/{hash}', requirements: ['hash' => '[a-zA-Z0-9]{24,}'])]
149+
#[FrontpageRoute(verb: 'GET', url: '/s/{hash}', requirements: ['hash' => self::PUBLIC_SHARE_HASH_REQUIREMENT])]
149150
public function publicLinkView(string $hash): Response {
150151
try {
151152
$share = $this->shareMapper->findPublicShareByHash($hash);

lib/Controller/ShareApiController.php

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -301,6 +301,85 @@ public function updateShare(int $formId, int $shareId, array $keyValuePairs): Da
301301
return new DataResponse($formShare->getId());
302302
}
303303

304+
/**
305+
* Update token/hash of a public link share
306+
*
307+
* @param int $formId of the form
308+
* @param int $shareId of the share to update
309+
* @param string $token The new share token
310+
* @return DataResponse<Http::STATUS_OK, int, array{}>
311+
* @throws OCSBadRequestException Share doesn't belong to given Form
312+
* @throws OCSBadRequestException Invalid share token
313+
* @throws OCSBadRequestException Share hash exists, please retry
314+
* @throws OCSForbiddenException Custom public share tokens are not allowed
315+
* @throws OCSForbiddenException Not allowed to update token on non-link share
316+
* @throws OCSForbiddenException This form is not owned by the current user
317+
* @throws OCSNotFoundException Could not find share
318+
*
319+
* 200: the id of the updated share
320+
*/
321+
#[CORS()]
322+
#[NoAdminRequired()]
323+
#[ApiRoute(verb: 'PATCH', url: '/api/v3/forms/{formId}/shares/{shareId}/token')]
324+
public function updateShareToken(int $formId, int $shareId, string $token): DataResponse {
325+
$this->logger->debug('Updating share token: {shareId} of form {formId}', [
326+
'formId' => $formId,
327+
'shareId' => $shareId,
328+
]);
329+
330+
if (!$this->configService->getAllowCustomPublicToken()) {
331+
$this->logger->debug('Custom public share tokens are not allowed.');
332+
throw new OCSForbiddenException('Custom public share tokens are not allowed.');
333+
}
334+
335+
$form = $this->formsService->getFormIfAllowed($formId);
336+
if ($this->formsService->isFormArchived($form)) {
337+
$this->logger->debug('This form is archived and can not be modified');
338+
throw new OCSForbiddenException('This form is archived and can not be modified');
339+
}
340+
341+
try {
342+
$formShare = $this->shareMapper->findById($shareId);
343+
} catch (IMapperException $e) {
344+
$this->logger->debug('Could not find share', ['exception' => $e]);
345+
throw new OCSNotFoundException('Could not find share');
346+
}
347+
348+
if ($formId !== $formShare->getFormId()) {
349+
$this->logger->debug('This share doesn\'t belong to the given Form');
350+
throw new OCSBadRequestException('Share doesn\'t belong to given Form');
351+
}
352+
353+
if ($formShare->getShareType() !== IShare::TYPE_LINK) {
354+
$this->logger->debug('Not allowed to update token on non-link share');
355+
throw new OCSForbiddenException('Not allowed to update token on non-link share');
356+
}
357+
358+
if ($token === $formShare->getShareWith()) {
359+
return new DataResponse($formShare->getId());
360+
}
361+
362+
$this->validatePublicShareToken($token);
363+
364+
try {
365+
$existingShare = $this->shareMapper->findPublicShareByHash($token);
366+
if ($existingShare->getId() !== $formShare->getId()) {
367+
$this->logger->debug('Share hash already exists.');
368+
throw new OCSBadRequestException('Share hash exists, please retry.');
369+
}
370+
} catch (DoesNotExistException $e) {
371+
// Just continue, this is what we expect to happen (share hash not existing yet).
372+
}
373+
374+
$this->formsService->obtainFormLock($form);
375+
376+
$formShare->setShareWith($token);
377+
$formShare = $this->shareMapper->update($formShare);
378+
$this->formMapper->update($form);
379+
380+
return new DataResponse($formShare->getId());
381+
}
382+
304383
/**
305384
* Delete a share
306385
*
@@ -421,4 +500,22 @@ private function validatePermissions(array $permissions, int $shareType): bool {
421500
}
422501
return true;
423502
}
503+
504+
/**
505+
* @throws OCSBadRequestException If token does not satisfy basic safety checks
506+
*/
507+
private function validatePublicShareToken(string $token): void {
508+
if ($token !== trim($token)) {
509+
throw new OCSBadRequestException('Invalid share token');
510+
}
511+
512+
$tokenLength = strlen($token);
513+
if ($tokenLength < Constants::PUBLIC_SHARE_TOKEN_MIN_LENGTH || $tokenLength > Constants::PUBLIC_SHARE_TOKEN_MAX_LENGTH) {
514+
throw new OCSBadRequestException('Invalid share token');
515+
}
516+
517+
if (preg_match('/^[a-zA-Z0-9]+$/', $token) !== 1) {
518+
throw new OCSBadRequestException('Invalid share token');
519+
}
520+
}
424521
}

tests/Unit/Controller/ShareApiControllerTest.php

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -952,4 +952,155 @@ public function testUpdateShare_NotExistingForm() {
952952
$this->expectException(NoSuchFormException::class);
953953
$this->shareApiController->updateShare(7331, 1337, [Constants::PERMISSION_SUBMIT]);
954954
}
955+
956+
public function testUpdateShareToken() {
957+
$form = new Form();
958+
$form->setId('5');
959+
$form->setOwnerId('currentUser');
960+
961+
$this->configService->expects($this->once())
962+
->method('getAllowCustomPublicToken')
963+
->willReturn(true);
964+
965+
$this->formsService->expects($this->once())
966+
->method('getFormIfAllowed')
967+
->with(5)
968+
->willReturn($form);
969+
970+
$share = new Share();
971+
$share->setId(8);
972+
$share->setFormId(5);
973+
$share->setShareType(IShare::TYPE_LINK);
974+
$share->setShareWith('abcdefgh');
975+
$share->setPermissions([Constants::PERMISSION_SUBMIT]);
976+
977+
$this->shareMapper->expects($this->once())
978+
->method('findById')
979+
->with(8)
980+
->willReturn($share);
981+
982+
$this->shareMapper->expects($this->once())
983+
->method('findPublicShareByHash')
984+
->with('tokenabcd')
985+
->willThrowException(new DoesNotExistException('Not found'));
986+
987+
$this->shareMapper->expects($this->once())
988+
->method('update')
989+
->willReturnCallback(function (Share $updatedShare) {
990+
$this->assertSame('tokenabcd', $updatedShare->getShareWith());
991+
return $updatedShare;
992+
});
993+
994+
$this->assertEquals(new DataResponse(8), $this->shareApiController->updateShareToken(5, 8, 'tokenabcd'));
995+
}
996+
997+
public function testUpdateShareToken_CustomTokensDisabled() {
998+
$this->configService->expects($this->once())
999+
->method('getAllowCustomPublicToken')
1000+
->willReturn(false);
1001+
1002+
$this->expectException(OCSForbiddenException::class);
1003+
$this->shareApiController->updateShareToken(5, 8, 'tokenabcd');
1004+
}
1005+
1006+
public function testUpdateShareToken_ForbiddenForNonLinkShare() {
1007+
$form = new Form();
1008+
$form->setId('5');
1009+
$form->setOwnerId('currentUser');
1010+
1011+
$this->configService->expects($this->once())
1012+
->method('getAllowCustomPublicToken')
1013+
->willReturn(true);
1014+
1015+
$this->formsService->expects($this->once())
1016+
->method('getFormIfAllowed')
1017+
->with(5)
1018+
->willReturn($form);
1019+
1020+
$share = new Share();
1021+
$share->setId(8);
1022+
$share->setFormId(5);
1023+
$share->setShareType(IShare::TYPE_USER);
1024+
$share->setShareWith('user1');
1025+
1026+
$this->shareMapper->expects($this->once())
1027+
->method('findById')
1028+
->with(8)
1029+
->willReturn($share);
1030+
1031+
$this->expectException(OCSForbiddenException::class);
1032+
$this->shareApiController->updateShareToken(5, 8, 'tokenabcd');
1033+
}
1034+
1035+
public function testUpdateShareToken_DuplicateHash() {
1036+
$form = new Form();
1037+
$form->setId('5');
1038+
$form->setOwnerId('currentUser');
1039+
1040+
$this->configService->expects($this->once())
1041+
->method('getAllowCustomPublicToken')
1042+
->willReturn(true);
1043+
1044+
$this->formsService->expects($this->once())
1045+
->method('getFormIfAllowed')
1046+
->with(5)
1047+
->willReturn($form);
1048+
1049+
$currentShare = new Share();
1050+
$currentShare->setId(8);
1051+
$currentShare->setFormId(5);
1052+
$currentShare->setShareType(IShare::TYPE_LINK);
1053+
$currentShare->setShareWith('abcdefgh');
1054+
1055+
$existingShare = new Share();
1056+
$existingShare->setId(9);
1057+
$existingShare->setFormId(5);
1058+
$existingShare->setShareType(IShare::TYPE_LINK);
1059+
$existingShare->setShareWith('tokenabcd');
1060+
1061+
$this->shareMapper->expects($this->once())
1062+
->method('findById')
1063+
->with(8)
1064+
->willReturn($currentShare);
1065+
1066+
$this->shareMapper->expects($this->once())
1067+
->method('findPublicShareByHash')
1068+
->with('tokenabcd')
1069+
->willReturn($existingShare);
1070+
1071+
$this->expectException(OCSBadRequestException::class);
1072+
$this->shareApiController->updateShareToken(5, 8, 'tokenabcd');
1073+
}
1074+
1075+
public function testUpdateShareToken_InvalidToken() {
1076+
$form = new Form();
1077+
$form->setId('5');
1078+
$form->setOwnerId('currentUser');
1079+
1080+
$this->configService->expects($this->once())
1081+
->method('getAllowCustomPublicToken')
1082+
->willReturn(true);
1083+
1084+
$this->formsService->expects($this->once())
1085+
->method('getFormIfAllowed')
1086+
->with(5)
1087+
->willReturn($form);
1088+
1089+
$share = new Share();
1090+
$share->setId(8);
1091+
$share->setFormId(5);
1092+
$share->setShareType(IShare::TYPE_LINK);
1093+
$share->setShareWith('abcdefgh');
1094+
1095+
$this->shareMapper->expects($this->once())
1096+
->method('findById')
1097+
->with(8)
1098+
->willReturn($share);
1099+
1100+
$this->shareMapper->expects($this->never())
1101+
->method('update');
1102+
1103+
$this->expectException(OCSBadRequestException::class);
1104+
$this->shareApiController->updateShareToken(5, 8, 'invalid-token');
1105+
}
9551106
}

0 commit comments

Comments
 (0)