Skip to content

Commit c79ad18

Browse files
authored
Merge pull request #50669 from nextcloud/fix/files-show-details-when-no-action
fix(files): Do not download files with openfile query flag
2 parents 72e0bb7 + fcdfb9e commit c79ad18

File tree

9 files changed

+264
-50
lines changed

9 files changed

+264
-50
lines changed

apps/files/src/components/FilesListVirtual.vue

Lines changed: 40 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -57,9 +57,10 @@
5757
</template>
5858

5959
<script lang="ts">
60-
import type { ComponentPublicInstance, PropType } from 'vue'
61-
import type { Node as NcNode } from '@nextcloud/files'
6260
import type { UserConfig } from '../types'
61+
import type { Node as NcNode } from '@nextcloud/files'
62+
import type { ComponentPublicInstance, PropType } from 'vue'
63+
import type { Location } from 'vue-router'
6364
6465
import { defineComponent } from 'vue'
6566
import { getFileListHeaders, Folder, Permission, View, getFileActions, FileType } from '@nextcloud/files'
@@ -219,13 +220,12 @@ export default defineComponent({
219220
},
220221
221222
openFile: {
222-
handler() {
223-
// wait for scrolling and updating the actions to settle
224-
this.$nextTick(() => {
225-
if (this.fileId && this.openFile) {
226-
this.handleOpenFile(this.fileId)
227-
}
228-
})
223+
async handler(openFile) {
224+
if (!openFile || !this.fileId) {
225+
return
226+
}
227+
228+
await this.handleOpenFile(this.fileId)
229229
},
230230
immediate: true,
231231
},
@@ -329,30 +329,42 @@ export default defineComponent({
329329
* Handle opening a file (e.g. by ?openfile=true)
330330
* @param fileId File to open
331331
*/
332-
handleOpenFile(fileId: number|null) {
333-
if (fileId === null) {
332+
async handleOpenFile(fileId: number) {
333+
const node = this.nodes.find(n => n.fileid === fileId) as NcNode
334+
if (node === undefined) {
334335
return
335336
}
336337
337-
const node = this.nodes.find(n => n.fileid === fileId) as NcNode
338-
if (node === undefined || node.type === FileType.Folder) {
339-
return
338+
if (node.type === FileType.File) {
339+
const defaultAction = getFileActions()
340+
// Get only default actions (visible and hidden)
341+
.filter((action) => !!action?.default)
342+
// Find actions that are either always enabled or enabled for the current node
343+
.filter((action) => !action.enabled || action.enabled([node], this.currentView))
344+
.filter((action) => action.id !== 'download')
345+
// Sort enabled default actions by order
346+
.sort((a, b) => (a.order || 0) - (b.order || 0))
347+
// Get the first one
348+
.at(0)
349+
350+
// Some file types do not have a default action (e.g. they can only be downloaded)
351+
// So if there is an enabled default action, so execute it
352+
if (defaultAction) {
353+
logger.debug('Opening file ' + node.path, { node })
354+
return await defaultAction.exec(node, this.currentView, this.currentFolder.path)
355+
}
340356
}
357+
// The file is either a folder or has no default action other than downloading
358+
// in this case we need to open the details instead and remove the route from the history
359+
const query = this.$route.query
360+
delete query.openfile
361+
query.opendetails = ''
341362
342-
logger.debug('Opening file ' + node.path, { node })
343-
this.openFileId = fileId
344-
const defaultAction = getFileActions()
345-
// Get only default actions (visible and hidden)
346-
.filter(action => !!action?.default)
347-
// Find actions that are either always enabled or enabled for the current node
348-
.filter((action) => !action.enabled || action.enabled([node], this.currentView))
349-
// Sort enabled default actions by order
350-
.sort((a, b) => (a.order || 0) - (b.order || 0))
351-
// Get the first one
352-
.at(0)
353-
// Some file types do not have a default action (e.g. they can only be downloaded)
354-
// So if there is an enabled default action, so execute it
355-
defaultAction?.exec(node, this.currentView, this.currentFolder.path)
363+
logger.debug('Ignore `openfile` query and replacing with `opendetails` for ' + node.path, { node })
364+
await this.$router.replace({
365+
...(this.$route as Location),
366+
query,
367+
})
356368
},
357369
358370
onDragOver(event: DragEvent) {

apps/files/src/router/router.ts

Lines changed: 28 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,20 +3,41 @@
33
* SPDX-License-Identifier: AGPL-3.0-or-later
44
*/
55
import type { RawLocation, Route } from 'vue-router'
6-
import type { ErrorHandler } from 'vue-router/types/router.d.ts'
7-
86
import { generateUrl } from '@nextcloud/router'
97
import queryString from 'query-string'
10-
import Router from 'vue-router'
8+
import Router, { isNavigationFailure, NavigationFailureType } from 'vue-router'
119
import Vue from 'vue'
10+
import logger from '../logger'
1211

1312
Vue.use(Router)
1413

1514
// Prevent router from throwing errors when we're already on the page we're trying to go to
16-
const originalPush = Router.prototype.push as (to, onComplete?, onAbort?) => Promise<Route>
17-
Router.prototype.push = function push(to: RawLocation, onComplete?: ((route: Route) => void) | undefined, onAbort?: ErrorHandler | undefined): Promise<Route> {
18-
if (onComplete || onAbort) return originalPush.call(this, to, onComplete, onAbort)
19-
return originalPush.call(this, to).catch(err => err)
15+
const originalPush = Router.prototype.push
16+
Router.prototype.push = (function(this: Router, ...args: Parameters<typeof originalPush>) {
17+
if (args.length > 1) {
18+
return originalPush.call(this, ...args)
19+
}
20+
return originalPush.call<Router, [RawLocation], Promise<Route>>(this, args[0]).catch(ignoreDuplicateNavigation)
21+
}) as typeof originalPush
22+
23+
const originalReplace = Router.prototype.replace
24+
Router.prototype.replace = (function(this: Router, ...args: Parameters<typeof originalReplace>) {
25+
if (args.length > 1) {
26+
return originalReplace.call(this, ...args)
27+
}
28+
return originalReplace.call<Router, [RawLocation], Promise<Route>>(this, args[0]).catch(ignoreDuplicateNavigation)
29+
}) as typeof originalReplace
30+
31+
/**
32+
* Ignore duplicated-navigation error but forward real exceptions
33+
* @param error The thrown error
34+
*/
35+
function ignoreDuplicateNavigation(error: unknown): void {
36+
if (isNavigationFailure(error, NavigationFailureType.duplicated)) {
37+
logger.debug('Ignoring duplicated navigation from vue-router', { error })
38+
} else {
39+
throw error
40+
}
2041
}
2142

2243
const router = new Router({
Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
/*!
2+
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
3+
* SPDX-License-Identifier: AGPL-3.0-or-later
4+
*/
5+
6+
import type { User } from '@nextcloud/cypress'
7+
import { join } from 'path'
8+
import { getRowForFileId } from './FilesUtils.ts'
9+
10+
/**
11+
* Check that the sidebar is opened for a specific file
12+
* @param name The name of the file
13+
*/
14+
function sidebarIsOpen(name: string): void {
15+
cy.get('[data-cy-sidebar]')
16+
.should('be.visible')
17+
.findByRole('heading', { name })
18+
.should('be.visible')
19+
}
20+
21+
/**
22+
* Skip a test without viewer installed
23+
*/
24+
function skipIfViewerDisabled(this: Mocha.Context): void {
25+
cy.runOccCommand('app:list --enabled --output json')
26+
.then((exec) => exec.stdout)
27+
.then((output) => JSON.parse(output))
28+
.then((obj) => 'viewer' in obj.enabled)
29+
.then((enabled) => {
30+
if (!enabled) {
31+
this.skip()
32+
}
33+
})
34+
}
35+
36+
/**
37+
* Check a file was not downloaded
38+
* @param filename The expected filename
39+
*/
40+
function fileNotDownloaded(filename: string): void {
41+
const downloadsFolder = Cypress.config('downloadsFolder')
42+
cy.readFile(join(downloadsFolder, filename)).should('not.exist')
43+
}
44+
45+
describe('Check router query flags:', function() {
46+
let user: User
47+
let imageId: number
48+
let archiveId: number
49+
let folderId: number
50+
51+
before(() => {
52+
cy.createRandomUser().then(($user) => {
53+
user = $user
54+
cy.uploadFile(user, 'image.jpg')
55+
.then((response) => { imageId = Number.parseInt(response.headers['oc-fileid']) })
56+
cy.mkdir(user, '/folder')
57+
.then((response) => { folderId = Number.parseInt(response.headers['oc-fileid']) })
58+
cy.uploadContent(user, new Blob([]), 'application/zstd', '/archive.zst')
59+
.then((response) => { archiveId = Number.parseInt(response.headers['oc-fileid']) })
60+
cy.login(user)
61+
})
62+
})
63+
64+
describe('"opendetails"', () => {
65+
it('open details for known file type', () => {
66+
cy.visit(`/apps/files/files/${imageId}?opendetails`)
67+
68+
// see sidebar
69+
sidebarIsOpen('image.jpg')
70+
71+
// but no viewer
72+
cy.findByRole('dialog', { name: 'image.jpg' })
73+
.should('not.exist')
74+
75+
// and no download
76+
fileNotDownloaded('image.jpg')
77+
})
78+
79+
it('open details for unknown file type', () => {
80+
cy.visit(`/apps/files/files/${archiveId}?opendetails`)
81+
82+
// see sidebar
83+
sidebarIsOpen('archive.zst')
84+
85+
// but no viewer
86+
cy.findByRole('dialog', { name: 'archive.zst' })
87+
.should('not.exist')
88+
89+
// and no download
90+
fileNotDownloaded('archive.zst')
91+
})
92+
93+
it('open details for folder', () => {
94+
cy.visit(`/apps/files/files/${folderId}?opendetails`)
95+
96+
// see sidebar
97+
sidebarIsOpen('folder')
98+
99+
// but no viewer
100+
cy.findByRole('dialog', { name: 'folder' })
101+
.should('not.exist')
102+
103+
// and no download
104+
fileNotDownloaded('folder')
105+
})
106+
})
107+
108+
describe('"openfile"', function() {
109+
/** Check the viewer is open and shows the image */
110+
function viewerShowsImage(): void {
111+
cy.findByRole('dialog', { name: 'image.jpg' })
112+
.should('be.visible')
113+
.find(`img[src*="fileId=${imageId}"]`)
114+
.should('be.visible')
115+
}
116+
117+
it('opens files with default action', function() {
118+
skipIfViewerDisabled.call(this)
119+
120+
cy.visit(`/apps/files/files/${imageId}?openfile`)
121+
viewerShowsImage()
122+
})
123+
124+
it('opens files with default action using explicit query state', function() {
125+
skipIfViewerDisabled.call(this)
126+
127+
cy.visit(`/apps/files/files/${imageId}?openfile=true`)
128+
viewerShowsImage()
129+
})
130+
131+
it('does not open files with default action when using explicitly query value `false`', function() {
132+
skipIfViewerDisabled.call(this)
133+
134+
cy.visit(`/apps/files/files/${imageId}?openfile=false`)
135+
getRowForFileId(imageId)
136+
.should('be.visible')
137+
.and('have.class', 'files-list__row--active')
138+
139+
cy.findByRole('dialog', { name: 'image.jpg' })
140+
.should('not.exist')
141+
})
142+
143+
it('does not open folders but shows details', () => {
144+
cy.visit(`/apps/files/files/${folderId}?openfile`)
145+
146+
// See the URL was replaced
147+
cy.url()
148+
.should('match', /[?&]opendetails(&|=|$)/)
149+
.and('not.match', /openfile/)
150+
151+
// See the sidebar is correctly opened
152+
cy.get('[data-cy-sidebar]')
153+
.should('be.visible')
154+
.findByRole('heading', { name: 'folder' })
155+
.should('be.visible')
156+
157+
// see the folder was not changed
158+
getRowForFileId(imageId).should('exist')
159+
})
160+
161+
it('does not open unknown file types but shows details', () => {
162+
cy.visit(`/apps/files/files/${archiveId}?openfile`)
163+
164+
// See the URL was replaced
165+
cy.url()
166+
.should('match', /[?&]opendetails(&|=|$)/)
167+
.and('not.match', /openfile/)
168+
169+
// See the sidebar is correctly opened
170+
cy.get('[data-cy-sidebar]')
171+
.should('be.visible')
172+
.findByRole('heading', { name: 'archive.zst' })
173+
.should('be.visible')
174+
175+
// See no file was downloaded
176+
const downloadsFolder = Cypress.config('downloadsFolder')
177+
cy.readFile(join(downloadsFolder, 'archive.zst')).should('not.exist')
178+
})
179+
})
180+
})

cypress/support/commands.ts

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -54,12 +54,12 @@ Cypress.Commands.add('enableUser', (user: User, enable = true) => {
5454
*/
5555
Cypress.Commands.add('uploadFile', (user, fixture = 'image.jpg', mimeType = 'image/jpeg', target = `/${fixture}`) => {
5656
// get fixture
57-
return cy.fixture(fixture, 'base64').then(async file => {
58-
// convert the base64 string to a blob
59-
const blob = Cypress.Blob.base64StringToBlob(file, mimeType)
60-
61-
cy.uploadContent(user, blob, mimeType, target)
62-
})
57+
return cy.fixture(fixture, 'base64')
58+
.then((file) => (
59+
// convert the base64 string to a blob
60+
Cypress.Blob.base64StringToBlob(file, mimeType)
61+
))
62+
.then((blob) => cy.uploadContent(user, blob, mimeType, target))
6363
})
6464

6565
Cypress.Commands.add('setFileAsFavorite', (user: User, target: string, favorite = true) => {
@@ -98,7 +98,7 @@ Cypress.Commands.add('setFileAsFavorite', (user: User, target: string, favorite
9898

9999
Cypress.Commands.add('mkdir', (user: User, target: string) => {
100100
// eslint-disable-next-line cypress/unsafe-to-chain-command
101-
cy.clearCookies()
101+
return cy.clearCookies()
102102
.then(async () => {
103103
try {
104104
const rootPath = `${Cypress.env('baseUrl')}/remote.php/dav/files/${encodeURIComponent(user.userId)}`
@@ -112,6 +112,7 @@ Cypress.Commands.add('mkdir', (user: User, target: string) => {
112112
},
113113
})
114114
cy.log(`Created directory ${target}`, response)
115+
return response
115116
} catch (error) {
116117
cy.log('error', error)
117118
throw new Error('Unable to create directory')

cypress/support/cypress-e2e.d.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ declare global {
2121
* Upload a file from the fixtures folder to a given user storage.
2222
* **Warning**: Using this function will reset the previous session
2323
*/
24-
uploadFile(user: User, fixture?: string, mimeType?: string, target?: string): Cypress.Chainable<void>,
24+
uploadFile(user: User, fixture?: string, mimeType?: string, target?: string): Cypress.Chainable<AxiosResponse>,
2525

2626
/**
2727
* Upload a raw content to a given user storage.
@@ -38,7 +38,7 @@ declare global {
3838
* Create a new directory
3939
* **Warning**: Using this function will reset the previous session
4040
*/
41-
mkdir(user: User, target: string): Cypress.Chainable<void>,
41+
mkdir(user: User, target: string): Cypress.Chainable<AxiosResponse>,
4242

4343
/**
4444
* Set a file as favorite (or remove from favorite)

dist/core-common.js

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

dist/core-common.js.map

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

dist/files-main.js

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

dist/files-main.js.map

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)