Skip to content

Commit 76ed55a

Browse files
authored
Merge pull request #49317 from nextcloud/feat/edit-share-token
feat: Make it possible to customize share link tokens
2 parents 3fbc854 + a3cfc4f commit 76ed55a

31 files changed

+342
-54
lines changed

apps/files_sharing/lib/Capabilities.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ public function __construct(
5555
* send_mail?: bool,
5656
* upload?: bool,
5757
* upload_files_drop?: bool,
58+
* custom_tokens?: bool,
5859
* },
5960
* user: array{
6061
* send_mail: bool,
@@ -136,6 +137,7 @@ public function getCapabilities() {
136137
$public['send_mail'] = $this->config->getAppValue('core', 'shareapi_allow_public_notification', 'no') === 'yes';
137138
$public['upload'] = $this->shareManager->shareApiLinkAllowPublicUpload();
138139
$public['upload_files_drop'] = $public['upload'];
140+
$public['custom_tokens'] = $this->shareManager->allowCustomTokens();
139141
}
140142
$res['public'] = $public;
141143

apps/files_sharing/lib/Controller/ShareAPIController.php

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
use OCA\GlobalSiteSelector\Service\SlaveService;
2222
use OCP\App\IAppManager;
2323
use OCP\AppFramework\Http;
24+
use OCP\AppFramework\Http\Attribute\ApiRoute;
2425
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
2526
use OCP\AppFramework\Http\Attribute\UserRateLimit;
2627
use OCP\AppFramework\Http\DataResponse;
@@ -52,6 +53,7 @@
5253
use OCP\Mail\IMailer;
5354
use OCP\Server;
5455
use OCP\Share\Exceptions\ShareNotFound;
56+
use OCP\Share\Exceptions\ShareTokenException;
5557
use OCP\Share\IManager;
5658
use OCP\Share\IProviderFactory;
5759
use OCP\Share\IShare;
@@ -1164,6 +1166,7 @@ private function hasPermission(int $permissionsSet, int $permissionsToCheck): bo
11641166
* Considering the share already exists, no mail will be send after the share is updated.
11651167
* You will have to use the sendMail action to send the mail.
11661168
* @param string|null $shareWith New recipient for email shares
1169+
* @param string|null $token New token
11671170
* @return DataResponse<Http::STATUS_OK, Files_SharingShare, array{}>
11681171
* @throws OCSBadRequestException Share could not be updated because the requested changes are invalid
11691172
* @throws OCSForbiddenException Missing permissions to update the share
@@ -1184,6 +1187,7 @@ public function updateShare(
11841187
?string $hideDownload = null,
11851188
?string $attributes = null,
11861189
?string $sendMail = null,
1190+
?string $token = null,
11871191
): DataResponse {
11881192
try {
11891193
$share = $this->getShareById($id);
@@ -1211,7 +1215,8 @@ public function updateShare(
12111215
$label === null &&
12121216
$hideDownload === null &&
12131217
$attributes === null &&
1214-
$sendMail === null
1218+
$sendMail === null &&
1219+
$token === null
12151220
) {
12161221
throw new OCSBadRequestException($this->l->t('Wrong or no update parameter given'));
12171222
}
@@ -1324,6 +1329,16 @@ public function updateShare(
13241329
} elseif ($sendPasswordByTalk !== null) {
13251330
$share->setSendPasswordByTalk(false);
13261331
}
1332+
1333+
if ($token !== null) {
1334+
if (!$this->shareManager->allowCustomTokens()) {
1335+
throw new OCSForbiddenException($this->l->t('Custom share link tokens have been disabled by the administrator'));
1336+
}
1337+
if (!$this->validateToken($token)) {
1338+
throw new OCSBadRequestException($this->l->t('Tokens must contain at least 1 character and may only contain letters, numbers, or a hyphen'));
1339+
}
1340+
$share->setToken($token);
1341+
}
13271342
}
13281343

13291344
// NOT A LINK SHARE
@@ -1357,6 +1372,16 @@ public function updateShare(
13571372
return new DataResponse($this->formatShare($share));
13581373
}
13591374

1375+
private function validateToken(string $token): bool {
1376+
if (mb_strlen($token) === 0) {
1377+
return false;
1378+
}
1379+
if (!preg_match('/^[a-z0-9-]+$/i', $token)) {
1380+
return false;
1381+
}
1382+
return true;
1383+
}
1384+
13601385
/**
13611386
* Get all shares that are still pending
13621387
*
@@ -2152,4 +2177,26 @@ public function sendShareEmail(string $id, $password = ''): DataResponse {
21522177
throw new OCSNotFoundException($this->l->t('Wrong share ID, share does not exist'));
21532178
}
21542179
}
2180+
2181+
/**
2182+
* Get a unique share token
2183+
*
2184+
* @throws OCSException Failed to generate a unique token
2185+
*
2186+
* @return DataResponse<Http::STATUS_OK, array{token: string}, array{}>
2187+
*
2188+
* 200: Token generated successfully
2189+
*/
2190+
#[ApiRoute(verb: 'GET', url: '/api/v1/token')]
2191+
#[NoAdminRequired]
2192+
public function generateToken(): DataResponse {
2193+
try {
2194+
$token = $this->shareManager->generateToken();
2195+
return new DataResponse([
2196+
'token' => $token,
2197+
]);
2198+
} catch (ShareTokenException $e) {
2199+
throw new OCSException($this->l->t('Failed to generate a unique token'));
2200+
}
2201+
}
21552202
}

apps/files_sharing/openapi.json

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,9 @@
129129
},
130130
"upload_files_drop": {
131131
"type": "boolean"
132+
},
133+
"custom_tokens": {
134+
"type": "boolean"
132135
}
133136
}
134137
},
@@ -2313,6 +2316,11 @@
23132316
"type": "string",
23142317
"nullable": true,
23152318
"description": "if the share should be send by mail. Considering the share already exists, no mail will be send after the share is updated. You will have to use the sendMail action to send the mail."
2319+
},
2320+
"token": {
2321+
"type": "string",
2322+
"nullable": true,
2323+
"description": "New token"
23162324
}
23172325
}
23182326
}
@@ -3833,6 +3841,75 @@
38333841
}
38343842
}
38353843
}
3844+
},
3845+
"/ocs/v2.php/apps/files_sharing/api/v1/token": {
3846+
"get": {
3847+
"operationId": "shareapi-generate-token",
3848+
"summary": "Get a unique share token",
3849+
"tags": [
3850+
"shareapi"
3851+
],
3852+
"security": [
3853+
{
3854+
"bearer_auth": []
3855+
},
3856+
{
3857+
"basic_auth": []
3858+
}
3859+
],
3860+
"parameters": [
3861+
{
3862+
"name": "OCS-APIRequest",
3863+
"in": "header",
3864+
"description": "Required to be true for the API request to pass",
3865+
"required": true,
3866+
"schema": {
3867+
"type": "boolean",
3868+
"default": true
3869+
}
3870+
}
3871+
],
3872+
"responses": {
3873+
"200": {
3874+
"description": "Token generated successfully",
3875+
"content": {
3876+
"application/json": {
3877+
"schema": {
3878+
"type": "object",
3879+
"required": [
3880+
"ocs"
3881+
],
3882+
"properties": {
3883+
"ocs": {
3884+
"type": "object",
3885+
"required": [
3886+
"meta",
3887+
"data"
3888+
],
3889+
"properties": {
3890+
"meta": {
3891+
"$ref": "#/components/schemas/OCSMeta"
3892+
},
3893+
"data": {
3894+
"type": "object",
3895+
"required": [
3896+
"token"
3897+
],
3898+
"properties": {
3899+
"token": {
3900+
"type": "string"
3901+
}
3902+
}
3903+
}
3904+
}
3905+
}
3906+
}
3907+
}
3908+
}
3909+
}
3910+
}
3911+
}
3912+
}
38363913
}
38373914
},
38383915
"tags": []

apps/files_sharing/src/models/Share.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,13 @@ export default class Share {
192192
return this._share.token
193193
}
194194

195+
/**
196+
* Set the public share token
197+
*/
198+
set token(token: string) {
199+
this._share.token = token
200+
}
201+
195202
/**
196203
* Get the share note if any
197204
*/

apps/files_sharing/src/services/ConfigService.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,8 @@ type FileSharingCapabilities = {
3434
},
3535
send_mail: boolean,
3636
upload: boolean,
37-
upload_files_drop: boolean
37+
upload_files_drop: boolean,
38+
custom_tokens: boolean,
3839
},
3940
resharing: boolean,
4041
user: {
@@ -298,4 +299,11 @@ export default class Config {
298299
return this._capabilities?.password_policy || {}
299300
}
300301

302+
/**
303+
* Returns true if custom tokens are allowed
304+
*/
305+
get allowCustomTokens(): boolean {
306+
return this._capabilities?.files_sharing?.public?.custom_tokens
307+
}
308+
301309
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
/**
2+
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
3+
* SPDX-License-Identifier: AGPL-3.0-or-later
4+
*/
5+
6+
import axios from '@nextcloud/axios'
7+
import { generateOcsUrl } from '@nextcloud/router'
8+
9+
interface TokenData {
10+
ocs: {
11+
data: {
12+
token: string,
13+
}
14+
}
15+
}
16+
17+
export const generateToken = async (): Promise<string> => {
18+
const { data } = await axios.get<TokenData>(generateOcsUrl('/apps/files_sharing/api/v1/token'))
19+
return data.ocs.data.token
20+
}

apps/files_sharing/src/views/SharingDetailsTab.vue

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,9 +105,23 @@
105105
role="region">
106106
<section>
107107
<NcInputField v-if="isPublicShare"
108+
class="sharingTabDetailsView__label"
108109
autocomplete="off"
109110
:label="t('files_sharing', 'Share label')"
110111
:value.sync="share.label" />
112+
<NcInputField v-if="config.allowCustomTokens && isPublicShare && !isNewShare"
113+
autocomplete="off"
114+
:label="t('files_sharing', 'Share link token')"
115+
:helper-text="t('files_sharing', 'Set the public share link token to something easy to remember or generate a new token. It is not recommended to use a guessable token for shares which contain sensitive information.')"
116+
show-trailing-button
117+
:trailing-button-label="loadingToken ? t('files_sharing', 'Generating…') : t('files_sharing', 'Generate new token')"
118+
@trailing-button-click="generateNewToken"
119+
:value.sync="share.token">
120+
<template #trailing-button-icon>
121+
<NcLoadingIcon v-if="loadingToken" />
122+
<Refresh v-else :size="20" />
123+
</template>
124+
</NcInputField>
111125
<template v-if="isPublicShare">
112126
<NcCheckboxRadioSwitch :checked.sync="isPasswordProtected" :disabled="isPasswordEnforced">
113127
{{ t('files_sharing', 'Set password') }}
@@ -228,7 +242,7 @@
228242
<div class="sharingTabDetailsView__footer">
229243
<div class="button-group">
230244
<NcButton data-cy-files-sharing-share-editor-action="cancel"
231-
@click="$emit('close-sharing-details')">
245+
@click="cancel">
232246
{{ t('files_sharing', 'Cancel') }}
233247
</NcButton>
234248
<NcButton type="primary"
@@ -248,6 +262,7 @@
248262
import { emit } from '@nextcloud/event-bus'
249263
import { getLanguage } from '@nextcloud/l10n'
250264
import { ShareType } from '@nextcloud/sharing'
265+
import { showError } from '@nextcloud/dialogs'
251266
import moment from '@nextcloud/moment'
252267
253268
import NcAvatar from '@nextcloud/vue/dist/Components/NcAvatar.js'
@@ -272,13 +287,15 @@ import UploadIcon from 'vue-material-design-icons/Upload.vue'
272287
import MenuDownIcon from 'vue-material-design-icons/MenuDown.vue'
273288
import MenuUpIcon from 'vue-material-design-icons/MenuUp.vue'
274289
import DotsHorizontalIcon from 'vue-material-design-icons/DotsHorizontal.vue'
290+
import Refresh from 'vue-material-design-icons/Refresh.vue'
275291
276292
import ExternalShareAction from '../components/ExternalShareAction.vue'
277293
278294
import GeneratePassword from '../utils/GeneratePassword.ts'
279295
import Share from '../models/Share.ts'
280296
import ShareRequests from '../mixins/ShareRequests.js'
281297
import SharesMixin from '../mixins/SharesMixin.js'
298+
import { generateToken } from '../services/TokenService.ts'
282299
import logger from '../services/logger.ts'
283300
284301
import {
@@ -311,6 +328,7 @@ export default {
311328
MenuDownIcon,
312329
MenuUpIcon,
313330
DotsHorizontalIcon,
331+
Refresh,
314332
},
315333
mixins: [ShareRequests, SharesMixin],
316334
props: {
@@ -339,6 +357,8 @@ export default {
339357
isFirstComponentLoad: true,
340358
test: false,
341359
creating: false,
360+
initialToken: this.share.token,
361+
loadingToken: false,
342362
343363
ExternalShareActions: OCA.Sharing.ExternalShareActions.state,
344364
}
@@ -766,6 +786,24 @@ export default {
766786
},
767787
768788
methods: {
789+
async generateNewToken() {
790+
if (this.loadingToken) {
791+
return
792+
}
793+
this.loadingToken = true
794+
try {
795+
this.share.token = await generateToken()
796+
} catch (error) {
797+
showError(t('files_sharing', 'Failed to generate a new token'))
798+
}
799+
this.loadingToken = false
800+
},
801+
802+
cancel() {
803+
this.share.token = this.initialToken
804+
this.$emit('close-sharing-details')
805+
},
806+
769807
updateAtomicPermissions({
770808
isReadChecked = this.hasRead,
771809
isEditChecked = this.canEdit,
@@ -876,6 +914,9 @@ export default {
876914
async saveShare() {
877915
const permissionsAndAttributes = ['permissions', 'attributes', 'note', 'expireDate']
878916
const publicShareAttributes = ['label', 'password', 'hideDownload']
917+
if (this.config.allowCustomTokens) {
918+
publicShareAttributes.push('token')
919+
}
879920
if (this.isPublicShare) {
880921
permissionsAndAttributes.push(...publicShareAttributes)
881922
}
@@ -1174,6 +1215,10 @@ export default {
11741215
}
11751216
}
11761217
1218+
&__label {
1219+
padding-block-end: 6px;
1220+
}
1221+
11771222
&__delete {
11781223
> button:first-child {
11791224
color: rgb(223, 7, 7);

0 commit comments

Comments
 (0)