Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Make it possible to customize share link tokens #49317

Merged
merged 12 commits into from
Jan 16, 2025
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)) {
Pytal marked this conversation as resolved.
Show resolved Hide resolved
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.')"
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@nextcloud/designers what do you think about this message? I think we should have something better that doesn't first incentivize using something easy and then reverting that in the second sentence

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
Loading