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: Allow editing public share token #49317

Draft
wants to merge 2 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 25 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 @@ -1163,6 +1164,7 @@
* 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 @@ -1183,6 +1185,7 @@
?string $hideDownload = null,
?string $attributes = null,
?string $sendMail = null,
?string $token = null,
): DataResponse {
try {
$share = $this->getShareById($id);
Expand Down Expand Up @@ -1210,7 +1213,8 @@
$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 @@ -1335,6 +1339,10 @@
} elseif ($sendPasswordByTalk !== null) {
$share->setSendPasswordByTalk(false);
}

if ($token !== null) {
$share->setToken($token);
}
}

// NOT A LINK SHARE
Expand Down Expand Up @@ -2151,4 +2159,20 @@
throw new OCSNotFoundException($this->l->t('Wrong share ID, share does not exist'));
}
}

/**
* Get a unique share token
*
* @return DataResponse<Http::STATUS_OK, array{token: string}>

Check failure on line 2166 in apps/files_sharing/lib/Controller/ShareAPIController.php

View workflow job for this annotation

GitHub Actions / static-code-analysis

MissingTemplateParam

apps/files_sharing/lib/Controller/ShareAPIController.php:2166:13: MissingTemplateParam: OCP\AppFramework\Http\DataResponse has missing template params, expecting 3 (see https://psalm.dev/182)
*
* 200: Token generated successfully
*/
#[ApiRoute('GET', '/api/v1/token')]

Check failure on line 2170 in apps/files_sharing/lib/Controller/ShareAPIController.php

View workflow job for this annotation

GitHub Actions / static-code-analysis

InvalidDocblock

apps/files_sharing/lib/Controller/ShareAPIController.php:2170:4: InvalidDocblock: Attribute arguments must be named. (see https://psalm.dev/008)
#[NoAdminRequired]
public function getToken(): DataResponse {
$token = $this->shareManager->generateToken();
return new DataResponse([
'token' => $token,
]);
}
}
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
31 changes: 31 additions & 0 deletions apps/files_sharing/src/services/TokenService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
/**
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

import axios from '@nextcloud/axios'
import { generateUrl } from '@nextcloud/router'
import logger from './logger.ts'

interface TokenData {
token: string,
}

export const generateToken = async (): Promise<null | string> => {
try {
const { data } = await axios.get<TokenData>(generateUrl('/api/v1/token'))
return data.token
} catch (error) {
logger.error('Failed to get token from server, falling back to client-side generation', { error })

const chars = 'abcdefgijkmnopqrstwxyzABCDEFGHJKLMNPQRSTWXYZ23456789'
const array = new Uint8Array(10)
const ratio = chars.length / 255
window.crypto.getRandomValues(array)
let token = ''
for (let i = 0; i < array.length; i++) {
token += chars.charAt(array[i] * ratio)
}
return token
}
}
31 changes: 31 additions & 0 deletions apps/files_sharing/src/views/SharingDetailsTab.vue
Original file line number Diff line number Diff line change
Expand Up @@ -105,9 +105,22 @@
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="isPublicShare && !isNewShare"
autocomplete="off"
:label="t('files_sharing', 'Link token')"
:helper-text="tokenHelperText"
show-trailing-button
:trailing-button-label="t('files_sharing', 'Generate new token')"
@trailing-button-click="generateNewToken"
:value.sync="share.token">
<template #trailing-button-icon>
<Reload />
</template>
</NcInputField>
<template v-if="isPublicShare">
<NcCheckboxRadioSwitch :checked.sync="isPasswordProtected" :disabled="isPasswordEnforced">
{{ t('files_sharing', 'Set password') }}
Expand Down Expand Up @@ -271,6 +284,7 @@ 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 Reload from 'vue-material-design-icons/Reload.vue'

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

Expand All @@ -279,6 +293,7 @@ import Share from '../models/Share.ts'
import ShareRequests from '../mixins/ShareRequests.js'
import ShareTypes from '../mixins/ShareTypes.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 +326,7 @@ export default {
MenuDownIcon,
MenuUpIcon,
DotsHorizontalIcon,
Reload,
},
mixins: [ShareTypes, ShareRequests, SharesMixin],
props: {
Expand Down Expand Up @@ -557,6 +573,13 @@ export default {
return t('files_sharing', 'Update share')

},

tokenHelperText() {
return t('files_sharing', 'Set the public link token. Access the share at /s/{token}', {
token: this.share.token || '<token>',
}, undefined, { escape: false, sanitize: false })
},

/**
* Can the sharer set whether the sharee can edit the file ?
*
Expand Down Expand Up @@ -763,6 +786,10 @@ export default {
},

methods: {
generateNewToken() {
this.share.token = generateToken()
},

updateAtomicPermissions({
isReadChecked = this.hasRead,
isEditChecked = this.canEdit,
Expand Down Expand Up @@ -1176,6 +1203,10 @@ export default {
}
}

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

&__delete {
>button:first-child {
color: rgb(223, 7, 7);
Expand Down
13 changes: 13 additions & 0 deletions lib/private/Share20/Exception/ShareTokenException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<?php

/**
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OC\Share20\Exception;

use Exception;

class ShareTokenException extends Exception {
}
76 changes: 41 additions & 35 deletions lib/private/Share20/Manager.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
use OC\Files\Mount\MoveableMount;
use OC\KnownUser\KnownUserService;
use OC\Share20\Exception\ProviderException;
use OC\Share20\Exception\ShareTokenException;
use OCA\Files_Sharing\AppInfo\Application;
use OCA\Files_Sharing\SharedStorage;
use OCP\EventDispatcher\IEventDispatcher;
Expand Down Expand Up @@ -657,41 +658,7 @@ public function createShare(IShare $share) {
$this->linkCreateChecks($share);
$this->setLinkParent($share);

// Initial token length
$tokenLength = \OC\Share\Helper::getTokenLength();

do {
$tokenExists = false;

for ($i = 0; $i <= 2; $i++) {
// Generate a new token
$token = $this->secureRandom->generate(
$tokenLength,
\OCP\Security\ISecureRandom::CHAR_HUMAN_READABLE
);

try {
// Try to fetch a share with the generated token
$this->getShareByToken($token);
$tokenExists = true; // Token exists, we need to try again
} catch (\OCP\Share\Exceptions\ShareNotFound $e) {
// Token is unique, exit the loop
$tokenExists = false;
break;
}
}

// If we've reached the maximum attempts and the token still exists, increase the token length
if ($tokenExists) {
$tokenLength++;

// Check if the token length exceeds the maximum allowed length
if ($tokenLength > \OC\Share\Constants::MAX_TOKEN_LENGTH) {
throw new \Exception('Unable to generate a unique share token. Maximum token length exceeded.');
}
}
} while ($tokenExists);

$token = $this->generateToken();
// Set the unique token
$share->setToken($token);

Expand Down Expand Up @@ -1992,4 +1959,43 @@ public function getAllShares(): iterable {
yield from $provider->getAllShares();
}
}

public function generateToken(): string {
// Initial token length
$tokenLength = \OC\Share\Helper::getTokenLength();

do {
$tokenExists = false;

for ($i = 0; $i <= 2; $i++) {
// Generate a new token
$token = $this->secureRandom->generate(
$tokenLength,
ISecureRandom::CHAR_HUMAN_READABLE,
);

try {
// Try to fetch a share with the generated token
$this->getShareByToken($token);
$tokenExists = true; // Token exists, we need to try again
} catch (ShareNotFound $e) {
// Token is unique, exit the loop
$tokenExists = false;
break;
}
}

// If we've reached the maximum attempts and the token still exists, increase the token length
if ($tokenExists) {
$tokenLength++;

// Check if the token length exceeds the maximum allowed length
if ($tokenLength > \OC\Share\Constants::MAX_TOKEN_LENGTH) {
throw new ShareTokenException('Unable to generate a unique share token. Maximum token length exceeded.');
}
}
} while ($tokenExists);

return $token;
}
}
9 changes: 9 additions & 0 deletions lib/public/Share/IManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
*/
namespace OCP\Share;

use OC\Share20\Exception\ShareTokenException;
use OCP\Files\Folder;
use OCP\Files\Node;

Expand Down Expand Up @@ -519,4 +520,12 @@ public function registerShareProvider(string $shareProviderClass): void;
* @since 18.0.0
*/
public function getAllShares(): iterable;

/**
* Generate a unique share token
*
* @throws ShareTokenException Failed to generate a unique token
* @since 31.0.0
*/
public function generateToken(): string;
}
Loading