Skip to content

Commit

Permalink
Merge pull request #49344 from nextcloud/backport/49203/stable30
Browse files Browse the repository at this point in the history
[stable30] fix(files): Allow downloading multiple nodes not from same base
  • Loading branch information
susnux authored Nov 20, 2024
2 parents 8ef6145 + 3aab8bd commit 14f3dbe
Show file tree
Hide file tree
Showing 20 changed files with 172 additions and 36 deletions.
94 changes: 89 additions & 5 deletions apps/files/src/actions/downloadAction.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,14 @@
*/
import { action } from './downloadAction'
import { expect } from '@jest/globals'
import { File, Folder, Permission, View, FileAction, DefaultType } from '@nextcloud/files'
import {
File,
Folder,
Permission,
View,
FileAction,
DefaultType,
} from '@nextcloud/files'

const view = {
id: 'files',
Expand Down Expand Up @@ -104,7 +111,9 @@ describe('Download action execute tests', () => {
// Silent action
expect(exec).toBe(null)
expect(link.download).toEqual('')
expect(link.href).toEqual('https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt')
expect(link.href).toEqual(
'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt',
)
expect(link.click).toHaveBeenCalledTimes(1)
})

Expand All @@ -122,7 +131,9 @@ describe('Download action execute tests', () => {
// Silent action
expect(exec).toStrictEqual([null])
expect(link.download).toEqual('')
expect(link.href).toEqual('https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt')
expect(link.href).toEqual(
'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt',
)
expect(link.click).toHaveBeenCalledTimes(1)
})

Expand All @@ -139,7 +150,11 @@ describe('Download action execute tests', () => {
// Silent action
expect(exec).toBe(null)
expect(link.download).toEqual('')
expect(link.href.startsWith('/index.php/apps/files/ajax/download.php?dir=%2F&files=%5B%22FooBar%22%5D&downloadStartSecret=')).toBe(true)
expect(
link.href.startsWith(
'/index.php/apps/files/ajax/download.php?dir=%2F&files=%5B%22FooBar%22%5D&downloadStartSecret=',
),
).toBe(true)
expect(link.click).toHaveBeenCalledTimes(1)
})

Expand All @@ -164,7 +179,76 @@ describe('Download action execute tests', () => {
// Silent action
expect(exec).toStrictEqual([null, null])
expect(link.download).toEqual('')
expect(link.href.startsWith('/index.php/apps/files/ajax/download.php?dir=%2FDir&files=%5B%22foo.txt%22%2C%22bar.txt%22%5D&downloadStartSecret=')).toBe(true)
expect(link.click).toHaveBeenCalledTimes(1)

expect(link.href).toMatch(
'/index.php/apps/files/ajax/download.php?dir=%2F&files=%5B%22foo.txt%22%2C%22bar.txt%22%5D&downloadStartSecret=',
)
})

test('Download multiple nodes from different sources', async () => {
const files = [
new File({
id: 1,
source: 'https://cloud.domain.com/remote.php/dav/files/admin/Folder 1/foo.txt',
owner: 'admin',
mime: 'text/plain',
permissions: Permission.READ,
}),
new File({
id: 2,
source: 'https://cloud.domain.com/remote.php/dav/files/admin/Folder 2/bar.txt',
owner: 'admin',
mime: 'text/plain',
permissions: Permission.READ,
}),
new File({
id: 3,
source: 'https://cloud.domain.com/remote.php/dav/files/admin/Folder 2/baz.txt',
owner: 'admin',
mime: 'text/plain',
permissions: Permission.READ,
}),
]

const exec = await action.execBatch!(files, view, '/Dir')

// Silent action
expect(exec).toStrictEqual([null, null, null])
expect(link.download).toEqual('')
expect(link.click).toHaveBeenCalledTimes(1)

expect(link.href).toMatch(
'/index.php/apps/files/ajax/download.php?dir=%2F&files=%5B%22foo.txt%22%2C%22bar.txt%22%2C%22baz.txt%22%5D&downloadStartSecret=',
)
})

test('Download node and parent folder', async () => {
const files = [
new File({
id: 1,
source: 'https://cloud.domain.com/remote.php/dav/files/admin/Folder 1/foo.txt',
owner: 'admin',
mime: 'text/plain',
permissions: Permission.READ,
}),
new Folder({
id: 2,
source: 'https://cloud.domain.com/remote.php/dav/files/admin/Folder 1',
owner: 'admin',
permissions: Permission.READ,
}),
]

const exec = await action.execBatch!(files, view, '/Dir')

// Silent action
expect(exec).toStrictEqual([null, null])
expect(link.download).toEqual('')
expect(link.click).toHaveBeenCalledTimes(1)

expect(link.href).toMatch(
'/index.php/apps/files/ajax/download.php?dir=%2F&files=%5B%22foo.txt%22%2C%22Folder%201%22%5D&downloadStartSecret=',
)
})
})
59 changes: 52 additions & 7 deletions apps/files/src/actions/downloadAction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,57 @@ const triggerDownload = function(url: string) {
hiddenElement.click()
}

const downloadNodes = function(dir: string, nodes: Node[]) {
/**
* Find the longest common path prefix of both input paths
* @param first The first path
* @param second The second path
*/
function longestCommonPath(first: string, second: string): string {
const firstSegments = first.split('/').filter(Boolean)
const secondSegments = second.split('/').filter(Boolean)
let base = '/'
for (const [index, segment] of firstSegments.entries()) {
if (index >= second.length) {
break
}
if (segment !== secondSegments[index]) {
break
}
const sep = base === '/' ? '' : '/'
base = `${base}${sep}${segment}`
}
return base
}

/**
* Handle downloading multiple nodes
* @param nodes The nodes to download
*/
function downloadNodes(nodes: Node[]): void {
// Remove nodes that are already included in parent folders
// Example: Download A/foo.txt and A will only return A as A/foo.txt is already included
const filteredNodes = nodes.filter((node) => {
const parent = nodes.find((other) => (
other.type === FileType.Folder
&& node.path.startsWith(`${other.path}/`)
))
return parent === undefined
})

let base = filteredNodes[0].dirname
for (const node of filteredNodes.slice(1)) {
base = longestCommonPath(base, node.dirname)
}
base = base || '/'

// Remove the common prefix
const filenames = filteredNodes.map((node) => node.path.slice(base === '/' ? 1 : (base.length + 1)))

const secret = Math.random().toString(36).substring(2)
const url = generateUrl('/apps/files/ajax/download.php?dir={dir}&files={files}&downloadStartSecret={secret}', {
dir,
const url = generateUrl('/apps/files/ajax/download.php?dir={base}&files={files}&downloadStartSecret={secret}', {
base,
secret,
files: JSON.stringify(nodes.map(node => node.basename)),
files: JSON.stringify(filenames),
})
triggerDownload(url)
}
Expand Down Expand Up @@ -67,9 +112,9 @@ export const action = new FileAction({
return nodes.every(isDownloadable)
},

async exec(node: Node, view: View, dir: string) {
async exec(node: Node) {
if (node.type === FileType.Folder) {
downloadNodes(dir, [node])
downloadNodes([node])
return null
}

Expand All @@ -83,7 +128,7 @@ export const action = new FileAction({
return [null]
}

downloadNodes(dir, nodes)
downloadNodes(nodes)
return new Array(nodes.length).fill(null)
},

Expand Down
2 changes: 1 addition & 1 deletion apps/files/src/actions/editLocallyAction.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ describe('Edit locally action enabled tests', () => {
describe('Edit locally action execute tests', () => {
test('Edit locally opens proper URL', async () => {
jest.spyOn(axios, 'post').mockImplementation(async () => ({
data: { ocs: { data: { token: 'foobar' } } }
data: { ocs: { data: { token: 'foobar' } } },
}))
const mockedShowError = jest.mocked(showError)
const spyDialogBuilder = jest.spyOn(dialogBuilder, 'build')
Expand Down
2 changes: 1 addition & 1 deletion apps/files/src/store/files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ export const useFilesStore = function(...args) {
*
* @param service The service (files view)
* @param path The path relative within the service
* @returns Array of cached nodes within the path
* @return Array of cached nodes within the path
*/
getNodesByPath(service: string, path?: string): Node[] {
const pathsStore = usePathsStore()
Expand Down
2 changes: 1 addition & 1 deletion apps/files/src/views/FilesList.vue
Original file line number Diff line number Diff line change
Expand Up @@ -422,7 +422,7 @@ export default defineComponent({

showCustomEmptyView() {
return !this.loading && this.isEmptyDir && this.currentView?.emptyView !== undefined
}
},
},

watch: {
Expand Down
6 changes: 6 additions & 0 deletions apps/files_external/src/actions/enterCredentialsAction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,12 @@ type CredentialResponse = {
password?: string,
}

/**
*
* @param node
* @param login
* @param password
*/
async function setCredentials(node: Node, login: string, password: string): Promise<null|true> {
const configResponse = await axios.put(generateUrl('apps/files_external/userglobalstorages/{id}', node.attributes), {
backendOptions: { user: login, password },
Expand Down
2 changes: 1 addition & 1 deletion apps/files_sharing/src/models/Share.ts
Original file line number Diff line number Diff line change
Expand Up @@ -314,7 +314,7 @@ export default class Share {

/**
* Get the shared item id
*/
*/
get fileSource(): number {
return this._share.file_source
}
Expand Down
1 change: 1 addition & 0 deletions apps/files_sharing/src/utils/GeneratePassword.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ const passwordSet = 'abcdefgijkmnopqrstwxyzABCDEFGHJKLMNPQRSTWXYZ23456789'
* Generate a valid policy password or
* request a valid password if password_policy
* is enabled
* @param verbose
*/
export default async function(verbose = false): Promise<string> {
// password policy is enabled, let's request a pass
Expand Down
10 changes: 5 additions & 5 deletions apps/settings/src/components/AppStoreSidebar/AppDetailsTab.vue
Original file line number Diff line number Diff line change
Expand Up @@ -343,16 +343,16 @@ export default {
.sort((a, b) => a.name.localeCompare(b.name))
},
},
mounted() {
if (this.app.groups.length > 0) {
this.groupCheckedAppsData = true
}
},
watch: {
'app.id'() {
this.removeData = false
},
},
mounted() {
if (this.app.groups.length > 0) {
this.groupCheckedAppsData = true
}
},
methods: {
toggleRemoveData() {
this.removeData = !this.removeData
Expand Down
2 changes: 1 addition & 1 deletion dist/5153-5153.js.map

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion dist/9725-9725.js.map

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions dist/files-init.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion dist/files-init.js.map

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions dist/files-main.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion dist/files-main.js.map

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion dist/files_external-init.js.map

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions dist/settings-apps-view-4529.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion dist/settings-apps-view-4529.js.map

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions dist/settings-vue-settings-apps-users-management.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion dist/settings-vue-settings-apps-users-management.js.map

Large diffs are not rendered by default.

0 comments on commit 14f3dbe

Please sign in to comment.