Skip to content

Commit d81ceef

Browse files
susnuxKoc
authored andcommitted
fix: Hide download action when file does not provide download permissions
This is not only a possibility for public shares but also for internal shares, the current code only "checked" public shares. This adds the same logic we use in the files app. Probably something to move to `@nextcloud/sharing` but for the moment lets just reuse here. Signed-off-by: Ferdinand Thiessen <[email protected]> [skip ci]
1 parent f2aa57a commit d81ceef

File tree

5 files changed

+155
-55
lines changed

5 files changed

+155
-55
lines changed

cypress/e2e/download-forbidden.cy.ts

+67
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
/**
2+
* SPDX-License: AGPL-3.0-or-later
3+
* SPDX-: Nextcloud GmbH and Nextcloud contributors
4+
*/
5+
6+
import type { User } from '@nextcloud/cypress'
7+
import { ShareType } from '@nextcloud/sharing'
8+
9+
describe('Disable download button if forbidden', { testIsolation: true }, () => {
10+
let sharee: User
11+
12+
before(() => {
13+
cy.createRandomUser().then((user) => { sharee = user })
14+
cy.createRandomUser().then((user) => {
15+
// Upload test files
16+
cy.createFolder(user, '/Photos')
17+
cy.uploadFile(user, 'image1.jpg', 'image/jpeg', '/Photos/image1.jpg')
18+
19+
cy.login(user)
20+
cy.createShare('/Photos',
21+
{ shareWith: sharee.userId, shareType: ShareType.User, attributes: [{ scope: 'permissions', key: 'download', value: false }] },
22+
)
23+
cy.logout()
24+
})
25+
})
26+
27+
beforeEach(() => {
28+
cy.login(sharee)
29+
cy.visit('/apps/files')
30+
cy.openFile('Photos')
31+
})
32+
33+
it('See the shared folder and images in files list', () => {
34+
cy.getFile('image1.jpg', { timeout: 10000 })
35+
.should('contain', 'image1 .jpg')
36+
})
37+
38+
// TODO: Fix no-download files on server
39+
it.skip('See the image can be shown', () => {
40+
cy.getFile('image1.jpg').should('be.visible')
41+
cy.openFile('image1.jpg')
42+
cy.get('body > .viewer').should('be.visible')
43+
44+
cy.get('body > .viewer', { timeout: 10000 })
45+
.should('be.visible')
46+
.and('have.class', 'modal-mask')
47+
.and('not.have.class', 'icon-loading')
48+
})
49+
50+
it('See the title on the viewer header but not the Download nor the menu button', () => {
51+
cy.getFile('image1.jpg').should('be.visible')
52+
cy.openFile('image1.jpg')
53+
cy.get('body > .viewer .modal-header__name').should('contain', 'image1.jpg')
54+
55+
cy.get('[role="dialog"]')
56+
.should('be.visible')
57+
.find('button[aria-label="Actions"]')
58+
.click()
59+
60+
cy.get('[role="menu"]:visible')
61+
.find('button')
62+
.should('have.length', 2)
63+
.each(($el) => {
64+
expect($el.text()).to.match(/(Full screen|Open sidebar)/i)
65+
})
66+
})
67+
})

cypress/support/commands.ts

+39-22
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,11 @@
2020
*
2121
*/
2222

23-
import { addCommands, User } from '@nextcloud/cypress'
24-
import { basename } from 'path'
25-
import axios from '@nextcloud/axios'
23+
import { addCommands } from '@nextcloud/cypress'
24+
import { Permission } from '@nextcloud/files'
25+
import { ShareType } from '@nextcloud/sharing'
2626
import { addCompareSnapshotCommand } from 'cypress-visual-regression/dist/command'
27+
import { basename } from 'path'
2728

2829
addCommands()
2930
addCompareSnapshotCommand()
@@ -125,32 +126,48 @@ Cypress.Commands.add(
125126
},
126127
)
127128

129+
interface ShareOptions {
130+
shareType: number
131+
shareWith?: string
132+
permissions: number
133+
attributes?: { value: boolean, key: string, scope: string}[]
134+
}
135+
136+
Cypress.Commands.add('createShare', (path: string, shareOptions?: ShareOptions) => {
137+
return cy.request('/csrftoken').then(({ body }) => {
138+
const requesttoken = body.token
139+
140+
return cy.request({
141+
method: 'POST',
142+
url: '../ocs/v2.php/apps/files_sharing/api/v1/shares?format=json',
143+
headers: {
144+
requesttoken,
145+
},
146+
body: {
147+
path,
148+
permissions: Permission.READ,
149+
...shareOptions,
150+
attributes: shareOptions?.attributes && JSON.stringify(shareOptions.attributes),
151+
},
152+
}).then(({ body }) => {
153+
const shareToken = body.ocs?.data?.token
154+
if (shareToken === undefined) {
155+
throw new Error('Invalid OCS response')
156+
}
157+
cy.log('Share link created', shareToken)
158+
return cy.wrap(shareToken)
159+
})
160+
})
161+
})
162+
128163
/**
129164
* Create a share link and return the share url
130165
*
131166
* @param {string} path the file/folder path
132167
* @return {string} the share link url
133168
*/
134169
Cypress.Commands.add('createLinkShare', path => {
135-
return cy.window().then(async window => {
136-
try {
137-
const request = await axios.post(`${Cypress.env('baseUrl')}/ocs/v2.php/apps/files_sharing/api/v1/shares`, {
138-
path,
139-
shareType: window.OC.Share.SHARE_TYPE_LINK,
140-
}, {
141-
headers: {
142-
requesttoken: window.OC.requestToken,
143-
},
144-
})
145-
if (!('ocs' in request.data) || !('token' in request.data.ocs.data && request.data.ocs.data.token.length > 0)) {
146-
throw request
147-
}
148-
cy.log('Share link created', request.data.ocs.data.token)
149-
return cy.wrap(request.data.ocs.data.token)
150-
} catch (error) {
151-
console.error(error)
152-
}
153-
}).should('have.length', 15)
170+
return cy.createShare(path, { shareType: ShareType.Link })
154171
})
155172

156173
Cypress.Commands.overwrite('compareSnapshot', (originalFn, subject, name, options) => {

src/utils/canDownload.js

-25
This file was deleted.

src/utils/canDownload.ts

+24
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
/*!
2+
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
3+
* SPDX-License-Identifier: AGPL-3.0-or-later
4+
*/
5+
6+
import type { FileInfo } from './fileUtils'
7+
8+
/**
9+
* Check if download permissions are granted for a file
10+
* @param fileInfo The file info to check
11+
*/
12+
export function canDownload(fileInfo: FileInfo) {
13+
// TODO: This should probably be part of `@nextcloud/sharing`
14+
// check share attributes
15+
const shareAttributes = JSON.parse(fileInfo.shareAttributes || '[]')
16+
17+
if (shareAttributes && shareAttributes.length > 0) {
18+
const downloadAttribute = shareAttributes.find(({ scope, key }) => scope === 'permissions' && key === 'download')
19+
// We only forbid download if the attribute is *explicitly* set to 'false'
20+
return downloadAttribute?.value !== false
21+
}
22+
// otherwise return true (as the file needs read permission otherwise we would not have opened it)
23+
return true
24+
}

src/views/Viewer.vue

+25-8
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,6 @@
6262
:spread-navigation="true"
6363
:style="{ width: isSidebarShown ? `${sidebarPosition}px` : null }"
6464
:name="currentFile.basename"
65-
:view="currentFile.modal"
6665
class="viewer"
6766
size="full"
6867
@close="close"
@@ -99,21 +98,24 @@
9998
:close-after-click="true"
10099
:href="downloadPath">
101100
<template #icon>
102-
<Download :size="24" />
101+
<Download :size="20" />
103102
</template>
104103
{{ t('viewer', 'Download') }}
105104
</NcActionLink>
106105
<NcActionButton v-if="canDelete"
107106
:close-after-click="true"
108107
@click="onDelete">
109108
<template #icon>
110-
<Delete :size="22" />
109+
<Delete :size="20" />
111110
</template>
112111
{{ t('viewer', 'Delete') }}
113112
</NcActionButton>
114113
</template>
115114

116-
<div class="viewer__content" :class="contentClass" @click.self.exact="close">
115+
<div class="viewer__content"
116+
:class="contentClass"
117+
@click.self.exact="close"
118+
@contextmenu="preventContextMenu">
117119
<!-- COMPARE FILE -->
118120
<div v-if="comparisonFile && !comparisonFile.failed && showComparison" class="viewer__file-wrapper">
119121
<component :is="comparisonFile.modal"
@@ -203,7 +205,7 @@ import isFullscreen from '@nextcloud/vue/dist/Mixins/isFullscreen.js'
203205
import isMobile from '@nextcloud/vue/dist/Mixins/isMobile.js'
204206

205207
import { extractFilePaths, sortCompare } from '../utils/fileUtils.ts'
206-
import canDownload from '../utils/canDownload.js'
208+
import { canDownload } from '../utils/canDownload.ts'
207209
import cancelableRequest from '../utils/CancelableRequest.js'
208210
import Error from '../components/Error.vue'
209211
import File from '../models/file.js'
@@ -376,12 +378,16 @@ export default {
376378
},
377379

378380
/**
379-
* Is the current user allowed to download the file in public mode?
381+
* Is the current user allowed to download the file
380382
*
381383
* @return {boolean}
382384
*/
383385
canDownload() {
384-
return canDownload() && !this.comparisonFile
386+
// download not possible for comparison
387+
if (this.comparisonFile) {
388+
return false
389+
}
390+
return this.currentFile && canDownload(this.currentFile)
385391
},
386392

387393
/**
@@ -392,7 +398,7 @@ export default {
392398
*/
393399
canEdit() {
394400
return !this.isMobile
395-
&& canDownload()
401+
&& this.canDownload
396402
&& this.currentFile?.permissions?.includes('W')
397403
&& this.isImage
398404
&& !this.comparisonFile
@@ -580,6 +586,17 @@ export default {
580586
},
581587

582588
methods: {
589+
/**
590+
* If there is no download permission also hide the context menu.
591+
* @param {MouseEvent} event The mouse click event
592+
*/
593+
preventContextMenu(event) {
594+
if (this.canDownload) {
595+
return
596+
}
597+
event.preventDefault()
598+
},
599+
583600
async beforeOpen() {
584601
// initial loading start
585602
this.initiated = true

0 commit comments

Comments
 (0)