diff --git a/docs/API_v3.md b/docs/API_v3.md index 72656055d..1fe8f61d8 100644 --- a/docs/API_v3.md +++ b/docs/API_v3.md @@ -647,7 +647,12 @@ Update a single or all properties of an option-object | Parameter | Type | Description | |------------------|----------|-------------| | _keyValuePairs_ | Array | Array of key-value pairs to update | -- Restrictions: Currently only the _permissions_ can be updated. +- Restrictions: + - Allowed keys are _permissions_ and _token_. + - _token_ updates are only available when the admin setting _allowCustomPublicShareTokens_ is enabled. + - _token_ can only be updated on link shares. + - _token_ must be unique among link shares and only contain alphanumeric characters. + - _token_ length must be between 8 and 256 characters. - Response: **Status-Code OK**, as well as the id of the share object. ``` @@ -669,6 +674,21 @@ Update a single or all properties of an option-object "data": 5 ``` +### Generate a new Share Token + +Generates a new random token that can be used as a custom share link token. This endpoint is used when custom public share tokens are enabled by the admin setting _allowCustomPublicShareTokens_. + +- Endpoint: `/api/v3/token` +- Method: `GET` +- Parameters: None +- Response: **Status-Code OK**, as well as the generated token string. + +``` +"data": { + "token": "abcdefghijklmn1234567890" +} +``` + ## Submission Endpoints ### Get Form Submissions diff --git a/lib/Constants.php b/lib/Constants.php index 684278976..e25ea7473 100644 --- a/lib/Constants.php +++ b/lib/Constants.php @@ -15,6 +15,7 @@ class Constants { */ public const CONFIG_KEY_ALLOWPERMITALL = 'allowPermitAll'; public const CONFIG_KEY_ALLOWPUBLICLINK = 'allowPublicLink'; + public const CONFIG_KEY_ALLOWCUSTOMPUBLICTOKEN = 'allowCustomPublicShareTokens'; public const CONFIG_KEY_ALLOWSHOWTOALL = 'allowShowToAll'; public const CONFIG_KEY_CREATIONALLOWEDGROUPS = 'creationAllowedGroups'; public const CONFIG_KEY_RESTRICTCREATION = 'restrictCreation'; @@ -24,6 +25,7 @@ class Constants { public const CONFIG_KEYS = [ self::CONFIG_KEY_ALLOWPERMITALL, self::CONFIG_KEY_ALLOWPUBLICLINK, + self::CONFIG_KEY_ALLOWCUSTOMPUBLICTOKEN, self::CONFIG_KEY_ALLOWSHOWTOALL, self::CONFIG_KEY_CREATIONALLOWEDGROUPS, self::CONFIG_KEY_RESTRICTCREATION, @@ -34,6 +36,7 @@ class Constants { public const CONFIG_KEY_TYPES = [ self::CONFIG_KEY_ALLOWPERMITALL => 'bool', self::CONFIG_KEY_ALLOWPUBLICLINK => 'bool', + self::CONFIG_KEY_ALLOWCUSTOMPUBLICTOKEN => 'bool', self::CONFIG_KEY_ALLOWSHOWTOALL => 'bool', self::CONFIG_KEY_RESTRICTCREATION => 'bool', self::CONFIG_KEY_ALLOWCONFIRMATIONEMAIL => 'bool', @@ -41,6 +44,10 @@ class Constants { self::CONFIG_KEY_CONFIRMATIONEMAILRATELIMIT => 'int', ]; + public const PUBLIC_SHARE_TOKEN_MIN_LENGTH = 1; + public const PUBLIC_SHARE_TOKEN_MAX_LENGTH = 256; + public const PUBLIC_SHARE_HASH_REQUIREMENT = '[a-zA-Z0-9]{' . self::PUBLIC_SHARE_TOKEN_MIN_LENGTH . ',' . self::PUBLIC_SHARE_TOKEN_MAX_LENGTH . '}'; + /** * Maximum String lengths, the database is set to store. */ diff --git a/lib/Controller/PageController.php b/lib/Controller/PageController.php index 64f85f4a9..0ba6a6b8f 100644 --- a/lib/Controller/PageController.php +++ b/lib/Controller/PageController.php @@ -148,7 +148,7 @@ public function internalLinkView(string $hash): Response { #[NoAdminRequired()] #[NoCSRFRequired()] #[PublicPage()] - #[FrontpageRoute(verb: 'GET', url: '/s/{hash}', requirements: ['hash' => '[a-zA-Z0-9]{24,}'])] + #[FrontpageRoute(verb: 'GET', url: '/s/{hash}', requirements: ['hash' => Constants::PUBLIC_SHARE_HASH_REQUIREMENT])] public function publicLinkView(string $hash): Response { try { $share = $this->shareMapper->findPublicShareByHash($hash); diff --git a/lib/Controller/ShareApiController.php b/lib/Controller/ShareApiController.php index 544d55fd7..4a9b1cb14 100644 --- a/lib/Controller/ShareApiController.php +++ b/lib/Controller/ShareApiController.php @@ -204,7 +204,7 @@ public function newShare(int $formId, int $shareType, string $shareWith = '', ar } /** - * Update permissions of a share + * Update properties of a share * * @param int $formId of the form * @param int $shareId of the share to update @@ -212,9 +212,12 @@ public function newShare(int $formId, int $shareType, string $shareWith = '', ar * @return DataResponse * @throws OCSBadRequestException Share doesn't belong to given Form * @throws OCSBadRequestException Invalid permission given + * @throws OCSBadRequestException Invalid share token + * @throws OCSForbiddenException Custom public share tokens are not allowed * @throws OCSForbiddenException This form is not owned by the current user * @throws OCSForbiddenException Empty keyValuePairs, will not update - * @throws OCSForbiddenException Not allowed to update other properties than permissions + * @throws OCSForbiddenException Not allowed to update token on non-link share + * @throws OCSForbiddenException Not allowed to update unknown properties * @throws OCSNotFoundException Could not find share * * 200: the id of the updated share @@ -223,7 +226,7 @@ public function newShare(int $formId, int $shareType, string $shareWith = '', ar #[NoAdminRequired()] #[ApiRoute(verb: 'PATCH', url: '/api/v3/forms/{formId}/shares/{shareId}')] public function updateShare(int $formId, int $shareId, array $keyValuePairs): DataResponse { - $this->logger->debug('Updating share: {shareId} of form {formId}, permissions: {permissions}', [ + $this->logger->debug('Updating share: {shareId} of form {formId}, values: {keyValuePairs}', [ 'formId' => $formId, 'shareId' => $shareId, 'keyValuePairs' => $keyValuePairs @@ -253,22 +256,64 @@ public function updateShare(int $formId, int $shareId, array $keyValuePairs): Da throw new OCSForbiddenException('Empty keyValuePairs, will not update'); } - //Don't allow to change other properties than permissions - if (count($keyValuePairs) > 1 || !array_key_exists('permissions', $keyValuePairs)) { - $this->logger->debug('Not allowed to update other properties than permissions'); - throw new OCSForbiddenException('Not allowed to update other properties than permissions'); + $allowedKeys = ['permissions', 'token']; + foreach (array_keys($keyValuePairs) as $key) { + if (!in_array($key, $allowedKeys, true)) { + $this->logger->debug('Not allowed to update other properties than permissions or token'); + throw new OCSForbiddenException('Not allowed to update other properties than permissions or token'); + } } - if (!$this->validatePermissions($keyValuePairs['permissions'], $formShare->getShareType())) { + if (array_key_exists('permissions', $keyValuePairs) && !$this->validatePermissions($keyValuePairs['permissions'], $formShare->getShareType())) { throw new OCSBadRequestException('Invalid permission given'); } + if (array_key_exists('token', $keyValuePairs)) { + if (!$this->configService->getAllowCustomPublicToken()) { + $this->logger->debug('Custom public share tokens are not allowed.'); + throw new OCSForbiddenException('Custom public share tokens are not allowed.'); + } + + if ($formShare->getShareType() !== IShare::TYPE_LINK) { + $this->logger->debug('Not allowed to update token on non-link share'); + throw new OCSForbiddenException('Not allowed to update token on non-link share'); + } + + if (!is_string($keyValuePairs['token'])) { + throw new OCSBadRequestException('Invalid share token'); + } + + $token = $keyValuePairs['token']; + if (!array_key_exists('permissions', $keyValuePairs) && $token === $formShare->getShareWith()) { + return new DataResponse($formShare->getId()); + } + + if ($token !== $formShare->getShareWith()) { + $this->validatePublicShareToken($token); + + try { + $existingShare = $this->shareMapper->findPublicShareByHash($token); + if ($existingShare->getId() !== $formShare->getId()) { + $this->logger->debug('Share hash already exists.'); + throw new OCSBadRequestException('Invalid share token'); + } + } catch (DoesNotExistException $e) { + // Just continue, this is what we expect to happen (share hash not existing yet). + } + + $formShare->setShareWith($token); + } + } + $this->formsService->obtainFormLock($form); - $formShare->setPermissions($keyValuePairs['permissions']); + if (array_key_exists('permissions', $keyValuePairs)) { + $formShare->setPermissions($keyValuePairs['permissions']); + } $formShare = $this->shareMapper->update($formShare); - if (in_array($formShare->getShareType(), [IShare::TYPE_USER, IShare::TYPE_GROUP, IShare::TYPE_USERGROUP, IShare::TYPE_CIRCLE], true)) { + if (array_key_exists('permissions', $keyValuePairs) + && in_array($formShare->getShareType(), [IShare::TYPE_USER, IShare::TYPE_GROUP, IShare::TYPE_USERGROUP, IShare::TYPE_CIRCLE], true)) { if (in_array(Constants::PERMISSION_RESULTS, $keyValuePairs['permissions'], true)) { $userFolder = $this->rootFolder->getUserFolder($form->getOwnerId()); $uploadedFilesFolderPath = $this->filePathHelper->getFormUploadedFilesFolderPath($form); @@ -350,6 +395,32 @@ public function deleteShare(int $formId, int $shareId): DataResponse { return new DataResponse($shareId); } + /** + * Get a unique share token + * + * @throws OCSForbiddenException Custom public share tokens are not allowed + * + * @return DataResponse + * + * 200: Token generated successfully + */ + #[ApiRoute(verb: 'GET', url: '/api/v3/token')] + #[NoAdminRequired] + public function generateToken(): DataResponse { + if (!$this->configService->getAllowCustomPublicToken()) { + $this->logger->debug('Custom public share tokens are not allowed.'); + throw new OCSForbiddenException('Custom public share tokens are not allowed.'); + } + + $token = $this->secureRandom->generate( + 24, + ISecureRandom::CHAR_HUMAN_READABLE + ); + return new DataResponse([ + 'token' => $token, + ]); + } + private function removeUploadedFilesShare(Form $form, Share $formShare): void { if (!in_array($formShare->getShareType(), [IShare::TYPE_USER, IShare::TYPE_GROUP, IShare::TYPE_USERGROUP, IShare::TYPE_CIRCLE], true)) { return; @@ -418,4 +489,22 @@ private function validatePermissions(array $permissions, int $shareType): bool { } return true; } + + /** + * @throws OCSBadRequestException If token does not satisfy basic safety checks + */ + private function validatePublicShareToken(string $token): void { + if ($token !== trim($token)) { + throw new OCSBadRequestException('Invalid share token'); + } + + $tokenLength = strlen($token); + if ($tokenLength < Constants::PUBLIC_SHARE_TOKEN_MIN_LENGTH || $tokenLength > Constants::PUBLIC_SHARE_TOKEN_MAX_LENGTH) { + throw new OCSBadRequestException('Invalid share token'); + } + + if (preg_match('/^[a-zA-Z0-9]+$/', $token) !== 1) { + throw new OCSBadRequestException('Invalid share token'); + } + } } diff --git a/lib/Service/ConfigService.php b/lib/Service/ConfigService.php index ad73d953e..5a31ae2c9 100644 --- a/lib/Service/ConfigService.php +++ b/lib/Service/ConfigService.php @@ -38,7 +38,10 @@ public function getAllowPermitAll(): bool { public function getAllowPublicLink(): bool { return $this->appConfig->getAppValueBool(Constants::CONFIG_KEY_ALLOWPUBLICLINK, true); } - public function getAllowShowToAll() : bool { + public function getAllowCustomPublicToken(): bool { + return $this->appConfig->getAppValueBool(Constants::CONFIG_KEY_ALLOWCUSTOMPUBLICTOKEN, false); + } + public function getAllowShowToAll(): bool { return $this->appConfig->getAppValueBool(Constants::CONFIG_KEY_ALLOWSHOWTOALL, true); } private function getUnformattedCreationAllowedGroups(): array { @@ -74,6 +77,7 @@ public function getAppConfig(): array { return [ Constants::CONFIG_KEY_ALLOWPERMITALL => $this->getAllowPermitAll(), Constants::CONFIG_KEY_ALLOWPUBLICLINK => $this->getAllowPublicLink(), + Constants::CONFIG_KEY_ALLOWCUSTOMPUBLICTOKEN => $this->getAllowCustomPublicToken(), Constants::CONFIG_KEY_ALLOWSHOWTOALL => $this->getAllowShowToAll(), Constants::CONFIG_KEY_CREATIONALLOWEDGROUPS => $this->getCreationAllowedGroups(), Constants::CONFIG_KEY_RESTRICTCREATION => $this->getRestrictCreation(), diff --git a/openapi.json b/openapi.json index 3ddf90c46..06c460dc6 100644 --- a/openapi.json +++ b/openapi.json @@ -5196,7 +5196,7 @@ "/ocs/v2.php/apps/forms/api/v3/forms/{formId}/shares/{shareId}": { "patch": { "operationId": "share_api-update-share", - "summary": "Update permissions of a share", + "summary": "Update properties of a share", "description": "This endpoint allows CORS requests", "tags": [ "share_api" @@ -5293,7 +5293,7 @@ } }, "400": { - "description": "Invalid permission given", + "description": "Invalid share token", "content": { "application/json": { "schema": { @@ -5321,7 +5321,7 @@ } }, "403": { - "description": "Not allowed to update other properties than permissions", + "description": "Not allowed to update unknown properties", "content": { "application/json": { "schema": { @@ -5596,6 +5596,131 @@ } } } + }, + "/ocs/v2.php/apps/forms/api/v3/token": { + "get": { + "operationId": "share_api-generate-token", + "summary": "Get a unique share token", + "tags": [ + "share_api" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "403": { + "description": "Custom public share tokens are not allowed", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + }, + "200": { + "description": "Token generated successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "token" + ], + "properties": { + "token": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "401": { + "description": "Current user is not logged in", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + } } }, "tags": [] diff --git a/src/FormsSettings.vue b/src/FormsSettings.vue index 479e76b1e..84a9df06b 100644 --- a/src/FormsSettings.vue +++ b/src/FormsSettings.vue @@ -72,6 +72,13 @@ @update:modelValue="onAllowPublicLinkChange"> {{ t('forms', 'Allow sharing by link') }} + + {{ t('forms', 'Allow custom public share tokens') }} + - + import IconPlus from '@material-symbols/svg-400/outlined/add.svg?raw' +import IconCheck from '@material-symbols/svg-400/outlined/check.svg?raw' import IconCodeBrackets from '@material-symbols/svg-400/outlined/code.svg?raw' import IconCopyAll from '@material-symbols/svg-400/outlined/copy_all.svg?raw' import IconDelete from '@material-symbols/svg-400/outlined/delete.svg?raw' @@ -212,16 +244,20 @@ import IconAccountMultiple from '@material-symbols/svg-400/outlined/group.svg?ra import IconLinkBoxVariantOutline from '@material-symbols/svg-400/outlined/iframe.svg?raw' import IconLinkVariant from '@material-symbols/svg-400/outlined/link_2.svg?raw' import IconQr from '@material-symbols/svg-400/outlined/qr_code.svg?raw' +import IconRefresh from '@material-symbols/svg-400/outlined/refresh.svg?raw' import { getCurrentUser } from '@nextcloud/auth' import axios from '@nextcloud/axios' import { showError } from '@nextcloud/dialogs' import { loadState } from '@nextcloud/initial-state' import { generateOcsUrl } from '@nextcloud/router' +import debounce from 'debounce' import NcActionButton from '@nextcloud/vue/components/NcActionButton' import NcActionLink from '@nextcloud/vue/components/NcActionLink' import NcActions from '@nextcloud/vue/components/NcActions' import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch' import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper' +import NcInputField from '@nextcloud/vue/components/NcInputField' +import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon' import NcNoteCard from '@nextcloud/vue/components/NcNoteCard' import QRDialog from '../QRDialog.vue' import SharingSearchDiv from './SharingSearchDiv.vue' @@ -230,6 +266,7 @@ import FormsIcon from '../../../img/forms-dark.svg?raw' import PermissionTypes from '../../mixins/PermissionTypes.js' import ShareLinkMixin from '../../mixins/ShareLinkMixin.js' import ShareTypes from '../../mixins/ShareTypes.js' +import { INPUT_DEBOUNCE_MS } from '../../models/Constants.ts' import logger from '../../utils/Logger.js' import OcsResponse2Data from '../../utils/OcsResponse2Data.js' @@ -240,6 +277,8 @@ export default { NcActionButton, NcActionLink, NcCheckboxRadioSwitch, + NcInputField, + NcLoadingIcon, NcNoteCard, QRDialog, SharingSearchDiv, @@ -270,6 +309,7 @@ export default { setup() { return { FormsIcon, + IconCheck, IconCopyAll, IconPlus, IconCodeBrackets, @@ -278,6 +318,7 @@ export default { IconLinkBoxVariantOutline, IconAccountMultiple, IconQr, + IconRefresh, } }, @@ -285,6 +326,9 @@ export default { return { isLoading: false, appConfig: loadState(appName, 'appConfig'), + shareTokens: {}, + savingShareTokens: {}, + loadingShareTokenId: null, qrDialogText: '', } }, @@ -318,6 +362,21 @@ export default { }, }, + watch: { + publicLinkShares: { + immediate: true, + handler(shares) { + const nextShareTokens = {} + for (const share of shares) { + nextShareTokens[share.id] = + this.shareTokens[share.id] ?? share.shareWith + } + + this.shareTokens = nextShareTokens + }, + }, + }, + methods: { /** * Add share @@ -489,6 +548,91 @@ export default { this.$emit('update:formProp', 'access', newAccess) }, + getShareTokenInput(share) { + return this.shareTokens[share.id] ?? share.shareWith + }, + + isShareTokenSaving(share) { + return !!this.savingShareTokens[share.id] + }, + + isShareTokenLoading(share) { + return this.loadingShareTokenId === share.id + }, + + setShareTokenInput(share, token) { + this.shareTokens = { + ...this.shareTokens, + [share.id]: token, + } + this.updateShareToken(share) + }, + + async generateNewToken(share) { + this.loadingShareTokenId = share.id + + try { + const { data } = await axios.get( + generateOcsUrl('apps/forms/api/v3/token'), + ) + this.setShareTokenInput(share, data.ocs.data.token) + } catch (error) { + logger.error('Error while generating share token', { + error, + share, + }) + showError( + t('forms', 'There was an error while generating the link token'), + ) + } finally { + this.loadingShareTokenId = null + } + }, + + updateShareToken: debounce(async function (share) { + const token = this.shareTokens[share.id] ?? share.shareWith + this.loadingShareTokenId = share.id + this.savingShareTokens = { + ...this.savingShareTokens, + [share.id]: true, + } + + try { + const response = await axios.patch( + generateOcsUrl('apps/forms/api/v3/forms/{id}/shares/{shareId}', { + id: this.form.id, + shareId: share.id, + }), + { + keyValuePairs: { + token, + }, + }, + ) + + this.$emit('updateShare', { + ...share, + id: OcsResponse2Data(response), + shareWith: token, + }) + } catch (error) { + logger.error('Error while updating share token', { + error, + share, + token, + }) + showError( + t('forms', 'There was an error while updating the link token'), + ) + } finally { + this.savingShareTokens = { + ...this.savingShareTokens, + [share.id]: false, + } + this.loadingShareTokenId = null + } + }, INPUT_DEBOUNCE_MS), + openQrDialog(share) { this.qrDialogText = this.getPublicShareLink(share) }, diff --git a/tests/Unit/Controller/ConfigControllerTest.php b/tests/Unit/Controller/ConfigControllerTest.php index 6528d9544..128e35049 100644 --- a/tests/Unit/Controller/ConfigControllerTest.php +++ b/tests/Unit/Controller/ConfigControllerTest.php @@ -61,12 +61,14 @@ public static function dataUpdateAppConfig() { 'booleanAllowPermitAll' => [ 'configKey' => 'allowPermitAll', 'configValue' => true, - 'strConfig' => 'true' ], 'booleanAllowShowToAll' => [ 'configKey' => 'allowShowToAll', 'configValue' => true, - 'strConfig' => 'true' + ], + 'booleanAllowCustomPublicShareTokens' => [ + 'configKey' => 'allowCustomPublicShareTokens', + 'configValue' => true, ], 'arrayCreationAllowedGroups' => [ 'configKey' => 'creationAllowedGroups', @@ -74,7 +76,6 @@ public static function dataUpdateAppConfig() { 'admin', 'group1' ], - 'strConfig' => '["admin","group1"]' ] ]; } @@ -83,9 +84,8 @@ public static function dataUpdateAppConfig() { * * @param string $configKey * @param mixed $configValue - * @param string $strConfig The configValue as json-string */ - public function testUpdateAppConfig(string $configKey, $configValue, string $strConfig) { + public function testUpdateAppConfig(string $configKey, $configValue) { $this->logger->expects($this->once()) ->method('debug'); diff --git a/tests/Unit/Controller/ShareApiControllerTest.php b/tests/Unit/Controller/ShareApiControllerTest.php index 500dc3de2..7ec6366a1 100644 --- a/tests/Unit/Controller/ShareApiControllerTest.php +++ b/tests/Unit/Controller/ShareApiControllerTest.php @@ -913,4 +913,351 @@ public function testUpdateShare_NotExistingForm() { $this->expectException(NoSuchFormException::class); $this->shareApiController->updateShare(7331, 1337, [Constants::PERMISSION_SUBMIT]); } + + public function testUpdateShareToken() { + $form = new Form(); + $form->setId('5'); + $form->setOwnerId('currentUser'); + + $this->configService->expects($this->once()) + ->method('getAllowCustomPublicToken') + ->willReturn(true); + + $this->formsService->expects($this->once()) + ->method('getFormIfAllowed') + ->with(5) + ->willReturn($form); + + $share = new Share(); + $share->setId(8); + $share->setFormId(5); + $share->setShareType(IShare::TYPE_LINK); + $share->setShareWith('abcdefgh'); + $share->setPermissions([Constants::PERMISSION_SUBMIT]); + + $this->shareMapper->expects($this->once()) + ->method('findById') + ->with(8) + ->willReturn($share); + + $this->shareMapper->expects($this->once()) + ->method('findPublicShareByHash') + ->with('tokenabcd') + ->willThrowException(new DoesNotExistException('Not found')); + + $this->shareMapper->expects($this->once()) + ->method('update') + ->willReturnCallback(function (Share $updatedShare) { + $this->assertSame('tokenabcd', $updatedShare->getShareWith()); + return $updatedShare; + }); + + $this->assertEquals(new DataResponse(8), $this->shareApiController->updateShare(5, 8, ['token' => 'tokenabcd'])); + } + + public function testUpdateShareToken_CustomTokensDisabled() { + $form = new Form(); + $form->setId('5'); + $form->setOwnerId('currentUser'); + + $this->formsService->expects($this->once()) + ->method('getFormIfAllowed') + ->with(5) + ->willReturn($form); + + $share = new Share(); + $share->setId(8); + $share->setFormId(5); + $share->setShareType(IShare::TYPE_LINK); + $share->setShareWith('abcdefgh'); + + $this->shareMapper->expects($this->once()) + ->method('findById') + ->with(8) + ->willReturn($share); + + $this->configService->expects($this->once()) + ->method('getAllowCustomPublicToken') + ->willReturn(false); + + $this->expectException(OCSForbiddenException::class); + $this->shareApiController->updateShare(5, 8, ['token' => 'tokenabcd']); + } + + public function testUpdateShareToken_NonStringToken() { + $form = new Form(); + $form->setId('5'); + $form->setOwnerId('currentUser'); + + $this->configService->expects($this->once()) + ->method('getAllowCustomPublicToken') + ->willReturn(true); + + $this->formsService->expects($this->once()) + ->method('getFormIfAllowed') + ->with(5) + ->willReturn($form); + + $share = new Share(); + $share->setId(8); + $share->setFormId(5); + $share->setShareType(IShare::TYPE_LINK); + $share->setShareWith('abcdefgh'); + + $this->shareMapper->expects($this->once()) + ->method('findById') + ->with(8) + ->willReturn($share); + + $this->shareMapper->expects($this->never()) + ->method('findPublicShareByHash'); + + $this->expectException(OCSBadRequestException::class); + $this->shareApiController->updateShare(5, 8, ['token' => 123]); + } + + public function testUpdateShareToken_ForbiddenForNonLinkShare() { + $form = new Form(); + $form->setId('5'); + $form->setOwnerId('currentUser'); + + $this->configService->expects($this->once()) + ->method('getAllowCustomPublicToken') + ->willReturn(true); + + $this->formsService->expects($this->once()) + ->method('getFormIfAllowed') + ->with(5) + ->willReturn($form); + + $share = new Share(); + $share->setId(8); + $share->setFormId(5); + $share->setShareType(IShare::TYPE_USER); + $share->setShareWith('user1'); + + $this->shareMapper->expects($this->once()) + ->method('findById') + ->with(8) + ->willReturn($share); + + $this->expectException(OCSForbiddenException::class); + $this->shareApiController->updateShare(5, 8, ['token' => 'tokenabcd']); + } + + public function testUpdateShareToken_DuplicateHash() { + $form = new Form(); + $form->setId('5'); + $form->setOwnerId('currentUser'); + + $this->configService->expects($this->once()) + ->method('getAllowCustomPublicToken') + ->willReturn(true); + + $this->formsService->expects($this->once()) + ->method('getFormIfAllowed') + ->with(5) + ->willReturn($form); + + $currentShare = new Share(); + $currentShare->setId(8); + $currentShare->setFormId(5); + $currentShare->setShareType(IShare::TYPE_LINK); + $currentShare->setShareWith('abcdefgh'); + + $existingShare = new Share(); + $existingShare->setId(9); + $existingShare->setFormId(5); + $existingShare->setShareType(IShare::TYPE_LINK); + $existingShare->setShareWith('tokenabcd'); + + $this->shareMapper->expects($this->once()) + ->method('findById') + ->with(8) + ->willReturn($currentShare); + + $this->shareMapper->expects($this->once()) + ->method('findPublicShareByHash') + ->with('tokenabcd') + ->willReturn($existingShare); + + $this->expectException(OCSBadRequestException::class); + $this->shareApiController->updateShare(5, 8, ['token' => 'tokenabcd']); + } + + public function testUpdateShareToken_InvalidToken() { + $form = new Form(); + $form->setId('5'); + $form->setOwnerId('currentUser'); + + $this->configService->expects($this->once()) + ->method('getAllowCustomPublicToken') + ->willReturn(true); + + $this->formsService->expects($this->once()) + ->method('getFormIfAllowed') + ->with(5) + ->willReturn($form); + + $share = new Share(); + $share->setId(8); + $share->setFormId(5); + $share->setShareType(IShare::TYPE_LINK); + $share->setShareWith('abcdefgh'); + + $this->shareMapper->expects($this->once()) + ->method('findById') + ->with(8) + ->willReturn($share); + + $this->shareMapper->expects($this->never()) + ->method('update'); + + $this->expectException(OCSBadRequestException::class); + $this->shareApiController->updateShare(5, 8, ['token' => 'invalid-token']); + } + + public function testUpdateShareToken_WhitespaceToken() { + $form = new Form(); + $form->setId('5'); + $form->setOwnerId('currentUser'); + + $this->configService->expects($this->once()) + ->method('getAllowCustomPublicToken') + ->willReturn(true); + + $this->formsService->expects($this->once()) + ->method('getFormIfAllowed') + ->with(5) + ->willReturn($form); + + $share = new Share(); + $share->setId(8); + $share->setFormId(5); + $share->setShareType(IShare::TYPE_LINK); + $share->setShareWith('abcdefgh'); + + $this->shareMapper->expects($this->once()) + ->method('findById') + ->with(8) + ->willReturn($share); + + $this->shareMapper->expects($this->never()) + ->method('findPublicShareByHash'); + + $this->expectException(OCSBadRequestException::class); + $this->shareApiController->updateShare(5, 8, ['token' => ' customtoken ']); + } + + public function testUpdateShareToken_SameTokenReturnsEarly() { + $form = new Form(); + $form->setId('5'); + $form->setOwnerId('currentUser'); + + $this->configService->expects($this->once()) + ->method('getAllowCustomPublicToken') + ->willReturn(true); + + $this->formsService->expects($this->once()) + ->method('getFormIfAllowed') + ->with(5) + ->willReturn($form); + + $share = new Share(); + $share->setId(8); + $share->setFormId(5); + $share->setShareType(IShare::TYPE_LINK); + $share->setShareWith('sameToken123'); + + $this->shareMapper->expects($this->once()) + ->method('findById') + ->with(8) + ->willReturn($share); + + $this->shareMapper->expects($this->never()) + ->method('findPublicShareByHash'); + $this->shareMapper->expects($this->never()) + ->method('update'); + $this->formsService->expects($this->never()) + ->method('obtainFormLock'); + + $this->assertEquals(new DataResponse(8), $this->shareApiController->updateShare(5, 8, ['token' => 'sameToken123'])); + } + + public function testUpdateShareToken_ForeignShare() { + $form = new Form(); + $form->setId('5'); + $form->setOwnerId('currentUser'); + + $this->configService->expects($this->never()) + ->method('getAllowCustomPublicToken') + ->willReturn(true); + + $this->formsService->expects($this->once()) + ->method('getFormIfAllowed') + ->with(5) + ->willReturn($form); + + $share = new Share(); + $share->setId(8); + $share->setFormId(6); + $share->setShareType(IShare::TYPE_LINK); + $share->setShareWith('abcdefgh'); + + $this->shareMapper->expects($this->once()) + ->method('findById') + ->with(8) + ->willReturn($share); + + $this->expectException(OCSBadRequestException::class); + $this->shareApiController->updateShare(5, 8, ['token' => 'customtoken123']); + } + + public function testUpdateShareToken_ShareNotFound() { + $form = new Form(); + $form->setId('5'); + $form->setOwnerId('currentUser'); + + $this->configService->expects($this->never()) + ->method('getAllowCustomPublicToken') + ->willReturn(true); + + $this->formsService->expects($this->once()) + ->method('getFormIfAllowed') + ->with(5) + ->willReturn($form); + + $this->shareMapper->expects($this->once()) + ->method('findById') + ->with(8) + ->willThrowException(new DoesNotExistException('missing')); + + $this->expectException(OCSNotFoundException::class); + $this->shareApiController->updateShare(5, 8, ['token' => 'customtoken123']); + } + + public function testUpdateShareToken_ArchivedForm() { + $form = new Form(); + $form->setId('5'); + $form->setOwnerId('currentUser'); + + $this->configService->expects($this->never()) + ->method('getAllowCustomPublicToken') + ->willReturn(true); + + $this->formsService->expects($this->once()) + ->method('getFormIfAllowed') + ->with(5) + ->willReturn($form); + $this->formsService->expects($this->once()) + ->method('isFormArchived') + ->with($form) + ->willReturn(true); + + $this->shareMapper->expects($this->never()) + ->method('findById'); + + $this->expectException(OCSForbiddenException::class); + $this->shareApiController->updateShare(5, 8, ['token' => 'customtoken123']); + } } diff --git a/tests/Unit/Service/ConfigServiceTest.php b/tests/Unit/Service/ConfigServiceTest.php index 5f058bfbc..3f84e4175 100644 --- a/tests/Unit/Service/ConfigServiceTest.php +++ b/tests/Unit/Service/ConfigServiceTest.php @@ -57,6 +57,7 @@ public static function dataGetAppConfig() { 'strConfig' => [ 'allowPermitAll' => 'false', 'allowPublicLink' => 'false', + 'allowCustomPublicShareTokens' => 'true', 'allowShowToAll' => 'false', 'creationAllowedGroups' => '["group1", "group2", "nonExisting"]', 'restrictCreation' => 'true', @@ -71,6 +72,7 @@ public static function dataGetAppConfig() { 'expected' => [ 'allowPermitAll' => false, 'allowPublicLink' => false, + 'allowCustomPublicShareTokens' => true, 'allowShowToAll' => false, 'creationAllowedGroups' => [ [ @@ -160,6 +162,7 @@ public static function dataGetAppConfig_Default() { 'expected' => [ 'allowPermitAll' => true, 'allowPublicLink' => true, + 'allowCustomPublicShareTokens' => false, 'allowShowToAll' => true, 'creationAllowedGroups' => [], 'restrictCreation' => false,