mirror of
https://github.com/nextcloud/server.git
synced 2026-02-03 20:41:22 -05:00
fix(files): verify files are still accessible before downloading
Signed-off-by: John Molakvoæ (skjnldsv) <skjnldsv@protonmail.com>
This commit is contained in:
parent
1215a0b045
commit
1dcee8a8ae
3 changed files with 112 additions and 8 deletions
|
|
@ -14,4 +14,5 @@ export default {
|
|||
get: async () => ({ status: 200, data: {} }),
|
||||
delete: async () => ({ status: 200, data: {} }),
|
||||
post: async () => ({ status: 200, data: {} }),
|
||||
head: async () => ({ status: 200, data: {} }),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,14 @@ import { beforeAll, beforeEach, describe, expect, test, vi } from 'vitest'
|
|||
|
||||
import { action } from './downloadAction'
|
||||
|
||||
import axios from '@nextcloud/axios'
|
||||
import * as dialogs from '@nextcloud/dialogs'
|
||||
import * as eventBus from '@nextcloud/event-bus'
|
||||
|
||||
vi.mock('@nextcloud/axios')
|
||||
vi.mock('@nextcloud/dialogs')
|
||||
vi.mock('@nextcloud/event-bus')
|
||||
|
||||
const view = {
|
||||
id: 'files',
|
||||
name: 'Files',
|
||||
|
|
@ -188,4 +196,46 @@ describe('Download action execute tests', () => {
|
|||
expect(link.href).toMatch('https://cloud.domain.com/remote.php/dav/files/admin/Dir/?accept=zip&files=%5B%22foo.txt%22%2C%22bar.txt%22%5D')
|
||||
expect(link.click).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
test('Download fails with error', async () => {
|
||||
const file = new File({
|
||||
id: 1,
|
||||
source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt',
|
||||
owner: 'admin',
|
||||
mime: 'text/plain',
|
||||
permissions: Permission.READ,
|
||||
})
|
||||
vi.spyOn(axios, 'head').mockRejectedValue(new Error('File not found'))
|
||||
|
||||
const errorSpy = vi.spyOn(dialogs, 'showError')
|
||||
const exec = await action.exec(file, view, '/')
|
||||
expect(exec).toBe(null)
|
||||
expect(errorSpy).toHaveBeenCalledWith('The requested file is not available.')
|
||||
expect(link.click).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('Download batch fails with error', async () => {
|
||||
const file1 = new File({
|
||||
id: 1,
|
||||
source: 'https://cloud.domain.com/remote.php/dav/files/admin/foo.txt',
|
||||
owner: 'admin',
|
||||
mime: 'text/plain',
|
||||
permissions: Permission.READ,
|
||||
})
|
||||
const file2 = new File({
|
||||
id: 2,
|
||||
source: 'https://cloud.domain.com/remote.php/dav/files/admin/bar.txt',
|
||||
owner: 'admin',
|
||||
mime: 'text/plain',
|
||||
permissions: Permission.READ,
|
||||
})
|
||||
vi.spyOn(axios, 'head').mockRejectedValue(new Error('File not found'))
|
||||
vi.spyOn(eventBus, 'emit').mockImplementation(() => {})
|
||||
|
||||
const errorSpy = vi.spyOn(dialogs, 'showError')
|
||||
const exec = await action.execBatch!([file1, file2], view, '/')
|
||||
expect(exec).toStrictEqual([null, null])
|
||||
expect(errorSpy).toHaveBeenCalledWith('The requested files are not available.')
|
||||
expect(link.click).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -4,18 +4,28 @@
|
|||
*/
|
||||
import type { Node, View } from '@nextcloud/files'
|
||||
import { FileAction, FileType, DefaultType } from '@nextcloud/files'
|
||||
import { showError } from '@nextcloud/dialogs'
|
||||
import { t } from '@nextcloud/l10n'
|
||||
import { isDownloadable } from '../utils/permissions'
|
||||
import axios from '@nextcloud/axios'
|
||||
|
||||
import ArrowDownSvg from '@mdi/svg/svg/arrow-down.svg?raw'
|
||||
|
||||
import { isDownloadable } from '../utils/permissions'
|
||||
import { usePathsStore } from '../store/paths'
|
||||
import { getPinia } from '../store'
|
||||
import { useFilesStore } from '../store/files'
|
||||
import { emit } from '@nextcloud/event-bus'
|
||||
|
||||
/**
|
||||
* Trigger downloading a file.
|
||||
*
|
||||
* @param url The url of the asset to download
|
||||
* @param name Optionally the recommended name of the download (browsers might ignore it)
|
||||
*/
|
||||
function triggerDownload(url: string, name?: string) {
|
||||
async function triggerDownload(url: string, name?: string) {
|
||||
// try to see if the resource is still available
|
||||
await axios.head(url)
|
||||
|
||||
const hiddenElement = document.createElement('a')
|
||||
hiddenElement.download = name ?? ''
|
||||
hiddenElement.href = url
|
||||
|
|
@ -44,12 +54,21 @@ function longestCommonPath(first: string, second: string): string {
|
|||
return base
|
||||
}
|
||||
|
||||
const downloadNodes = function(nodes: Node[]) {
|
||||
/**
|
||||
* Download the given nodes.
|
||||
*
|
||||
* If only one node is given, it will be downloaded directly.
|
||||
* If multiple nodes are given, they will be zipped and downloaded.
|
||||
*
|
||||
* @param nodes The node(s) to download
|
||||
*/
|
||||
async function downloadNodes(nodes: Node[]) {
|
||||
let url: URL
|
||||
|
||||
if (nodes.length === 1) {
|
||||
if (nodes[0].type === FileType.File) {
|
||||
return triggerDownload(nodes[0].encodedSource, nodes[0].displayname)
|
||||
await triggerDownload(nodes[0].encodedSource, nodes[0].displayname)
|
||||
return
|
||||
} else {
|
||||
url = new URL(nodes[0].encodedSource)
|
||||
url.searchParams.append('accept', 'zip')
|
||||
|
|
@ -72,7 +91,29 @@ const downloadNodes = function(nodes: Node[]) {
|
|||
url.pathname = `${url.pathname}/`
|
||||
}
|
||||
|
||||
return triggerDownload(url.href)
|
||||
await triggerDownload(url.href)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current directory node for the given view and path.
|
||||
* TODO: ideally the folder would directly be passed as exec params
|
||||
*
|
||||
* @param view The current view
|
||||
* @param directory The directory path
|
||||
* @return The current directory node or null if not found
|
||||
*/
|
||||
function getCurrentDirectory(view: View, directory: string): Node | null {
|
||||
const filesStore = useFilesStore(getPinia())
|
||||
const pathsStore = usePathsStore(getPinia())
|
||||
if (!view?.id) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (directory === '/') {
|
||||
return filesStore.getRoot(view.id) || null
|
||||
}
|
||||
const fileId = pathsStore.getPath(view.id, directory)!
|
||||
return filesStore.getNode(fileId) || null
|
||||
}
|
||||
|
||||
export const action = new FileAction({
|
||||
|
|
@ -101,12 +142,24 @@ export const action = new FileAction({
|
|||
},
|
||||
|
||||
async exec(node: Node) {
|
||||
downloadNodes([node])
|
||||
try {
|
||||
await downloadNodes([node])
|
||||
} catch (e) {
|
||||
showError(t('files', 'The requested file is not available.'))
|
||||
emit('files:node:deleted', node)
|
||||
}
|
||||
return null
|
||||
},
|
||||
|
||||
async execBatch(nodes: Node[]) {
|
||||
downloadNodes(nodes)
|
||||
async execBatch(nodes: Node[], view: View, dir: string) {
|
||||
try {
|
||||
await downloadNodes(nodes)
|
||||
} catch (e) {
|
||||
showError(t('files', 'The requested files are not available.'))
|
||||
// Try to reload the current directory to update the view
|
||||
const directory = getCurrentDirectory(view, dir)!
|
||||
emit('files:node:updated', directory)
|
||||
}
|
||||
return new Array(nodes.length).fill(null)
|
||||
},
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue