Skip to content

Commit

Permalink
Merge pull request #49317 from nextcloud/feat/edit-share-token
Browse files Browse the repository at this point in the history
feat: Make it possible to customize share link tokens
  • Loading branch information
Pytal authored Jan 16, 2025
2 parents 3fbc854 + a3cfc4f commit 76ed55a
Show file tree
Hide file tree
Showing 31 changed files with 342 additions and 54 deletions.
2 changes: 2 additions & 0 deletions apps/files_sharing/lib/Capabilities.php
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ public function __construct(
* send_mail?: bool,
* upload?: bool,
* upload_files_drop?: bool,
* custom_tokens?: bool,
* },
* user: array{
* send_mail: bool,
Expand Down Expand Up @@ -136,6 +137,7 @@ public function getCapabilities() {
$public['send_mail'] = $this->config->getAppValue('core', 'shareapi_allow_public_notification', 'no') === 'yes';
$public['upload'] = $this->shareManager->shareApiLinkAllowPublicUpload();
$public['upload_files_drop'] = $public['upload'];
$public['custom_tokens'] = $this->shareManager->allowCustomTokens();
}
$res['public'] = $public;

Expand Down
49 changes: 48 additions & 1 deletion apps/files_sharing/lib/Controller/ShareAPIController.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
use OCA\GlobalSiteSelector\Service\SlaveService;
use OCP\App\IAppManager;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\ApiRoute;
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
use OCP\AppFramework\Http\Attribute\UserRateLimit;
use OCP\AppFramework\Http\DataResponse;
Expand Down Expand Up @@ -52,6 +53,7 @@
use OCP\Mail\IMailer;
use OCP\Server;
use OCP\Share\Exceptions\ShareNotFound;
use OCP\Share\Exceptions\ShareTokenException;
use OCP\Share\IManager;
use OCP\Share\IProviderFactory;
use OCP\Share\IShare;
Expand Down Expand Up @@ -1164,6 +1166,7 @@ private function hasPermission(int $permissionsSet, int $permissionsToCheck): bo
* 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.
* @param string|null $shareWith New recipient for email shares
* @param string|null $token New token
* @return DataResponse<Http::STATUS_OK, Files_SharingShare, array{}>
* @throws OCSBadRequestException Share could not be updated because the requested changes are invalid
* @throws OCSForbiddenException Missing permissions to update the share
Expand All @@ -1184,6 +1187,7 @@ public function updateShare(
?string $hideDownload = null,
?string $attributes = null,
?string $sendMail = null,
?string $token = null,
): DataResponse {
try {
$share = $this->getShareById($id);
Expand Down Expand Up @@ -1211,7 +1215,8 @@ public function updateShare(
$label === null &&
$hideDownload === null &&
$attributes === null &&
$sendMail === null
$sendMail === null &&
$token === null
) {
throw new OCSBadRequestException($this->l->t('Wrong or no update parameter given'));
}
Expand Down Expand Up @@ -1324,6 +1329,16 @@ public function updateShare(
} elseif ($sendPasswordByTalk !== null) {
$share->setSendPasswordByTalk(false);
}

if ($token !== null) {
if (!$this->shareManager->allowCustomTokens()) {
throw new OCSForbiddenException($this->l->t('Custom share link tokens have been disabled by the administrator'));
}
if (!$this->validateToken($token)) {
throw new OCSBadRequestException($this->l->t('Tokens must contain at least 1 character and may only contain letters, numbers, or a hyphen'));
}
$share->setToken($token);
}
}

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

private function validateToken(string $token): bool {
if (mb_strlen($token) === 0) {
return false;
}
if (!preg_match('/^[a-z0-9-]+$/i', $token)) {
return false;
}
return true;
}

/**
* Get all shares that are still pending
*
Expand Down Expand Up @@ -2152,4 +2177,26 @@ public function sendShareEmail(string $id, $password = ''): DataResponse {
throw new OCSNotFoundException($this->l->t('Wrong share ID, share does not exist'));
}
}

/**
* Get a unique share token
*
* @throws OCSException Failed to generate a unique token
*
* @return DataResponse<Http::STATUS_OK, array{token: string}, array{}>
*
* 200: Token generated successfully
*/
#[ApiRoute(verb: 'GET', url: '/api/v1/token')]
#[NoAdminRequired]
public function generateToken(): DataResponse {
try {
$token = $this->shareManager->generateToken();
return new DataResponse([
'token' => $token,
]);
} catch (ShareTokenException $e) {
throw new OCSException($this->l->t('Failed to generate a unique token'));
}
}
}
77 changes: 77 additions & 0 deletions apps/files_sharing/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,9 @@
},
"upload_files_drop": {
"type": "boolean"
},
"custom_tokens": {
"type": "boolean"
}
}
},
Expand Down Expand Up @@ -2313,6 +2316,11 @@
"type": "string",
"nullable": true,
"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."
},
"token": {
"type": "string",
"nullable": true,
"description": "New token"
}
}
}
Expand Down Expand Up @@ -3833,6 +3841,75 @@
}
}
}
},
"/ocs/v2.php/apps/files_sharing/api/v1/token": {
"get": {
"operationId": "shareapi-generate-token",
"summary": "Get a unique share token",
"tags": [
"shareapi"
],
"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": {
"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"
}
}
}
}
}
}
}
}
}
}
}
}
}
},
"tags": []
Expand Down
7 changes: 7 additions & 0 deletions apps/files_sharing/src/models/Share.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,13 @@ export default class Share {
return this._share.token
}

/**
* Set the public share token
*/
set token(token: string) {
this._share.token = token
}

/**
* Get the share note if any
*/
Expand Down
10 changes: 9 additions & 1 deletion apps/files_sharing/src/services/ConfigService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,8 @@ type FileSharingCapabilities = {
},
send_mail: boolean,
upload: boolean,
upload_files_drop: boolean
upload_files_drop: boolean,
custom_tokens: boolean,
},
resharing: boolean,
user: {
Expand Down Expand Up @@ -298,4 +299,11 @@ export default class Config {
return this._capabilities?.password_policy || {}
}

/**
* Returns true if custom tokens are allowed
*/
get allowCustomTokens(): boolean {
return this._capabilities?.files_sharing?.public?.custom_tokens
}

}
20 changes: 20 additions & 0 deletions apps/files_sharing/src/services/TokenService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
/**
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

import axios from '@nextcloud/axios'
import { generateOcsUrl } from '@nextcloud/router'

interface TokenData {
ocs: {
data: {
token: string,
}
}
}

export const generateToken = async (): Promise<string> => {
const { data } = await axios.get<TokenData>(generateOcsUrl('/apps/files_sharing/api/v1/token'))
return data.ocs.data.token
}
47 changes: 46 additions & 1 deletion apps/files_sharing/src/views/SharingDetailsTab.vue
Original file line number Diff line number Diff line change
Expand Up @@ -105,9 +105,23 @@
role="region">
<section>
<NcInputField v-if="isPublicShare"
class="sharingTabDetailsView__label"
autocomplete="off"
:label="t('files_sharing', 'Share label')"
:value.sync="share.label" />
<NcInputField v-if="config.allowCustomTokens && isPublicShare && !isNewShare"
autocomplete="off"
:label="t('files_sharing', 'Share link token')"
: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.')"
show-trailing-button
:trailing-button-label="loadingToken ? t('files_sharing', 'Generating…') : t('files_sharing', 'Generate new token')"
@trailing-button-click="generateNewToken"
:value.sync="share.token">
<template #trailing-button-icon>
<NcLoadingIcon v-if="loadingToken" />
<Refresh v-else :size="20" />
</template>
</NcInputField>
<template v-if="isPublicShare">
<NcCheckboxRadioSwitch :checked.sync="isPasswordProtected" :disabled="isPasswordEnforced">
{{ t('files_sharing', 'Set password') }}
Expand Down Expand Up @@ -228,7 +242,7 @@
<div class="sharingTabDetailsView__footer">
<div class="button-group">
<NcButton data-cy-files-sharing-share-editor-action="cancel"
@click="$emit('close-sharing-details')">
@click="cancel">
{{ t('files_sharing', 'Cancel') }}
</NcButton>
<NcButton type="primary"
Expand All @@ -248,6 +262,7 @@
import { emit } from '@nextcloud/event-bus'
import { getLanguage } from '@nextcloud/l10n'
import { ShareType } from '@nextcloud/sharing'
import { showError } from '@nextcloud/dialogs'
import moment from '@nextcloud/moment'

import NcAvatar from '@nextcloud/vue/dist/Components/NcAvatar.js'
Expand All @@ -272,13 +287,15 @@ import UploadIcon from 'vue-material-design-icons/Upload.vue'
import MenuDownIcon from 'vue-material-design-icons/MenuDown.vue'
import MenuUpIcon from 'vue-material-design-icons/MenuUp.vue'
import DotsHorizontalIcon from 'vue-material-design-icons/DotsHorizontal.vue'
import Refresh from 'vue-material-design-icons/Refresh.vue'

import ExternalShareAction from '../components/ExternalShareAction.vue'

import GeneratePassword from '../utils/GeneratePassword.ts'
import Share from '../models/Share.ts'
import ShareRequests from '../mixins/ShareRequests.js'
import SharesMixin from '../mixins/SharesMixin.js'
import { generateToken } from '../services/TokenService.ts'
import logger from '../services/logger.ts'

import {
Expand Down Expand Up @@ -311,6 +328,7 @@ export default {
MenuDownIcon,
MenuUpIcon,
DotsHorizontalIcon,
Refresh,
},
mixins: [ShareRequests, SharesMixin],
props: {
Expand Down Expand Up @@ -339,6 +357,8 @@ export default {
isFirstComponentLoad: true,
test: false,
creating: false,
initialToken: this.share.token,
loadingToken: false,

ExternalShareActions: OCA.Sharing.ExternalShareActions.state,
}
Expand Down Expand Up @@ -766,6 +786,24 @@ export default {
},

methods: {
async generateNewToken() {
if (this.loadingToken) {
return
}
this.loadingToken = true
try {
this.share.token = await generateToken()
} catch (error) {
showError(t('files_sharing', 'Failed to generate a new token'))
}
this.loadingToken = false
},

cancel() {
this.share.token = this.initialToken
this.$emit('close-sharing-details')
},

updateAtomicPermissions({
isReadChecked = this.hasRead,
isEditChecked = this.canEdit,
Expand Down Expand Up @@ -876,6 +914,9 @@ export default {
async saveShare() {
const permissionsAndAttributes = ['permissions', 'attributes', 'note', 'expireDate']
const publicShareAttributes = ['label', 'password', 'hideDownload']
if (this.config.allowCustomTokens) {
publicShareAttributes.push('token')
}
if (this.isPublicShare) {
permissionsAndAttributes.push(...publicShareAttributes)
}
Expand Down Expand Up @@ -1174,6 +1215,10 @@ export default {
}
}

&__label {
padding-block-end: 6px;
}

&__delete {
> button:first-child {
color: rgb(223, 7, 7);
Expand Down
Loading

0 comments on commit 76ed55a

Please sign in to comment.