aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorFerdinand Thiessen <opensource@fthiessen.de>2024-11-15 01:49:44 +0100
committerFerdinand Thiessen <opensource@fthiessen.de>2025-01-15 16:54:47 +0100
commit2fb8a033038efa19c461dacad57cc00c6d039db1 (patch)
treec77d3cc61a23a3030ddc7a586e6bd6d4d44ffc18
parent47eedf9228efbf31136a1cc0c2efa747cada596d (diff)
downloadnextcloud-server-2fb8a033038efa19c461dacad57cc00c6d039db1.tar.gz
nextcloud-server-2fb8a033038efa19c461dacad57cc00c6d039db1.zip
refactor(files): Move reuseable parts of copy-move-action to service
The `handleCopyMoveNode` method is also used in other locations, so it makes sense to split the file into smaller pieces and make it reuseable through a service. Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
-rw-r--r--__mocks__/@nextcloud/sharing/public.ts8
-rw-r--r--apps/files/src/actions/moveOrCopyAction.ts184
-rw-r--r--apps/files/src/services/DropService.ts6
-rw-r--r--apps/files/src/services/MoveOrCopyService.ts201
-rw-r--r--apps/files/src/types.ts11
-rw-r--r--apps/files/src/utils/filePermissions.spec.ts199
-rw-r--r--apps/files/src/utils/filePermissions.ts (renamed from apps/files/src/actions/moveOrCopyActionUtils.ts)61
-rw-r--r--vitest.config.ts7
8 files changed, 460 insertions, 217 deletions
diff --git a/__mocks__/@nextcloud/sharing/public.ts b/__mocks__/@nextcloud/sharing/public.ts
new file mode 100644
index 00000000000..820b71e1c99
--- /dev/null
+++ b/__mocks__/@nextcloud/sharing/public.ts
@@ -0,0 +1,8 @@
+/**
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+export function isPublicShare() {
+ return false
+}
diff --git a/apps/files/src/actions/moveOrCopyAction.ts b/apps/files/src/actions/moveOrCopyAction.ts
index bd4ff450817..a750126950e 100644
--- a/apps/files/src/actions/moveOrCopyAction.ts
+++ b/apps/files/src/actions/moveOrCopyAction.ts
@@ -4,23 +4,17 @@
*/
import type { Folder, Node, View } from '@nextcloud/files'
import type { IFilePickerButton } from '@nextcloud/dialogs'
-import type { FileStat, ResponseDataDetailed, WebDAVClientError } from 'webdav'
-import type { MoveCopyResult } from './moveOrCopyActionUtils'
-import { isAxiosError } from '@nextcloud/axios'
-import { FilePickerClosed, getFilePickerBuilder, showError, showInfo, TOAST_PERMANENT_TIMEOUT } from '@nextcloud/dialogs'
-import { emit } from '@nextcloud/event-bus'
-import { FileAction, FileType, NodeStatus, davGetClient, davRootPath, davResultToNode, davGetDefaultPropfind, getUniqueName, Permission } from '@nextcloud/files'
+import { FilePickerClosed, getFilePickerBuilder, showError, showInfo } from '@nextcloud/dialogs'
+import { FileAction, Permission } from '@nextcloud/files'
import { translate as t } from '@nextcloud/l10n'
-import { openConflictPicker, hasConflict } from '@nextcloud/upload'
-import { basename, join } from 'path'
-import Vue from 'vue'
+import { basename } from 'path'
+import { MoveCopyAction, type MoveCopyResult } from '../types'
+import { canCopy, canMove } from '../utils/filePermissions.ts'
+import { handleCopyMoveNode } from '../services/MoveOrCopyService'
import CopyIconSvg from '@mdi/svg/svg/folder-multiple.svg?raw'
import FolderMoveSvg from '@mdi/svg/svg/folder-move.svg?raw'
-
-import { MoveCopyAction, canCopy, canMove, getQueue } from './moveOrCopyActionUtils'
-import { getContents } from '../services/Files'
import logger from '../logger'
/**
@@ -41,168 +35,6 @@ const getActionForNodes = (nodes: Node[]): MoveCopyAction => {
}
/**
- * Create a loading notification toast
- * @param mode The move or copy mode
- * @param source Name of the node that is copied / moved
- * @param destination Destination path
- * @return {() => void} Function to hide the notification
- */
-function createLoadingNotification(mode: MoveCopyAction, source: string, destination: string): () => void {
- const text = mode === MoveCopyAction.MOVE ? t('files', 'Moving "{source}" to "{destination}" …', { source, destination }) : t('files', 'Copying "{source}" to "{destination}" …', { source, destination })
-
- let toast: ReturnType<typeof showInfo>|undefined
- toast = showInfo(
- `<span class="icon icon-loading-small toast-loading-icon"></span> ${text}`,
- {
- isHTML: true,
- timeout: TOAST_PERMANENT_TIMEOUT,
- onRemove: () => { toast?.hideToast(); toast = undefined },
- },
- )
- return () => toast && toast.hideToast()
-}
-
-/**
- * Handle the copy/move of a node to a destination
- * This can be imported and used by other scripts/components on server
- * @param {Node} node The node to copy/move
- * @param {Folder} destination The destination to copy/move the node to
- * @param {MoveCopyAction} method The method to use for the copy/move
- * @param {boolean} overwrite Whether to overwrite the destination if it exists
- * @return {Promise<void>} A promise that resolves when the copy/move is done
- */
-export const handleCopyMoveNodeTo = async (node: Node, destination: Folder, method: MoveCopyAction.COPY | MoveCopyAction.MOVE, overwrite = false) => {
- if (!destination) {
- return
- }
-
- if (destination.type !== FileType.Folder) {
- throw new Error(t('files', 'Destination is not a folder'))
- }
-
- // Do not allow to MOVE a node to the same folder it is already located
- if (method === MoveCopyAction.MOVE && node.dirname === destination.path) {
- throw new Error(t('files', 'This file/folder is already in that directory'))
- }
-
- /**
- * Example:
- * - node: /foo/bar/file.txt -> path = /foo/bar/file.txt, destination: /foo
- * Allow move of /foo does not start with /foo/bar/file.txt so allow
- * - node: /foo , destination: /foo/bar
- * Do not allow as it would copy foo within itself
- * - node: /foo/bar.txt, destination: /foo
- * Allow copy a file to the same directory
- * - node: "/foo/bar", destination: "/foo/bar 1"
- * Allow to move or copy but we need to check with trailing / otherwise it would report false positive
- */
- if (`${destination.path}/`.startsWith(`${node.path}/`)) {
- throw new Error(t('files', 'You cannot move a file/folder onto itself or into a subfolder of itself'))
- }
-
- // Set loading state
- Vue.set(node, 'status', NodeStatus.LOADING)
- const actionFinished = createLoadingNotification(method, node.basename, destination.path)
-
- const queue = getQueue()
- return await queue.add(async () => {
- const copySuffix = (index: number) => {
- if (index === 1) {
- return t('files', '(copy)') // TRANSLATORS: Mark a file as a copy of another file
- }
- return t('files', '(copy %n)', undefined, index) // TRANSLATORS: Meaning it is the n'th copy of a file
- }
-
- try {
- const client = davGetClient()
- const currentPath = join(davRootPath, node.path)
- const destinationPath = join(davRootPath, destination.path)
-
- if (method === MoveCopyAction.COPY) {
- let target = node.basename
- // If we do not allow overwriting then find an unique name
- if (!overwrite) {
- const otherNodes = await client.getDirectoryContents(destinationPath) as FileStat[]
- target = getUniqueName(
- node.basename,
- otherNodes.map((n) => n.basename),
- {
- suffix: copySuffix,
- ignoreFileExtension: node.type === FileType.Folder,
- },
- )
- }
- await client.copyFile(currentPath, join(destinationPath, target))
- // If the node is copied into current directory the view needs to be updated
- if (node.dirname === destination.path) {
- const { data } = await client.stat(
- join(destinationPath, target),
- {
- details: true,
- data: davGetDefaultPropfind(),
- },
- ) as ResponseDataDetailed<FileStat>
- emit('files:node:created', davResultToNode(data))
- }
- } else {
- // show conflict file popup if we do not allow overwriting
- if (!overwrite) {
- const otherNodes = await getContents(destination.path)
- if (hasConflict([node], otherNodes.contents)) {
- try {
- // Let the user choose what to do with the conflicting files
- const { selected, renamed } = await openConflictPicker(destination.path, [node], otherNodes.contents)
- // two empty arrays: either only old files or conflict skipped -> no action required
- if (!selected.length && !renamed.length) {
- return
- }
- } catch (error) {
- // User cancelled
- showError(t('files', 'Move cancelled'))
- return
- }
- }
- }
- // getting here means either no conflict, file was renamed to keep both files
- // in a conflict, or the selected file was chosen to be kept during the conflict
- try {
- await client.moveFile(currentPath, join(destinationPath, node.basename))
- } catch (error) {
- const parser = new DOMParser()
- const text = await (error as WebDAVClientError).response?.text()
- const message = parser.parseFromString(text ?? '', 'text/xml')
- .querySelector('message')?.textContent
- if (message) {
- showError(message)
- }
- throw error
- }
- // Delete the node as it will be fetched again
- // when navigating to the destination folder
- emit('files:node:deleted', node)
- }
- } catch (error) {
- if (isAxiosError(error)) {
- if (error.response?.status === 412) {
- throw new Error(t('files', 'A file or folder with that name already exists in this folder'))
- } else if (error.response?.status === 423) {
- throw new Error(t('files', 'The files are locked'))
- } else if (error.response?.status === 404) {
- throw new Error(t('files', 'The file does not exist anymore'))
- } else if (error.message) {
- throw new Error(error.message)
- }
- }
- logger.debug(error as Error)
- throw new Error()
- } finally {
- Vue.set(node, 'status', '')
- actionFinished()
- }
- })
-}
-
-/**
* Open a file picker for the given action
* @param action The action to open the file picker for
* @param dir The directory to start the file picker in
@@ -334,7 +166,7 @@ export const action = new FileAction({
}
try {
- await handleCopyMoveNodeTo(node, result.destination, result.action)
+ await handleCopyMoveNode(node, result.destination, result.action)
return true
} catch (error) {
if (error instanceof Error && !!error.message) {
@@ -360,7 +192,7 @@ export const action = new FileAction({
const promises = nodes.map(async node => {
try {
- await handleCopyMoveNodeTo(node, result.destination, result.action)
+ await handleCopyMoveNode(node, result.destination, result.action)
return true
} catch (error) {
logger.error(`Failed to ${result.action} node`, { node, error })
diff --git a/apps/files/src/services/DropService.ts b/apps/files/src/services/DropService.ts
index 1013baeda6c..5713c407edf 100644
--- a/apps/files/src/services/DropService.ts
+++ b/apps/files/src/services/DropService.ts
@@ -15,8 +15,8 @@ import { translate as t } from '@nextcloud/l10n'
import Vue from 'vue'
import { Directory, traverseTree, resolveConflict, createDirectoryIfNotExists } from './DropServiceUtils'
-import { handleCopyMoveNodeTo } from '../actions/moveOrCopyAction'
-import { MoveCopyAction } from '../actions/moveOrCopyActionUtils'
+import { handleCopyMoveNode } from './MoveOrCopyService.ts'
+import { MoveCopyAction } from '../types.ts'
import logger from '../logger.ts'
/**
@@ -178,7 +178,7 @@ export const onDropInternalFiles = async (nodes: Node[], destination: Folder, co
for (const node of nodes) {
Vue.set(node, 'status', NodeStatus.LOADING)
- queue.push(handleCopyMoveNodeTo(node, destination, isCopy ? MoveCopyAction.COPY : MoveCopyAction.MOVE, true))
+ queue.push(handleCopyMoveNode(node, destination, isCopy ? MoveCopyAction.COPY : MoveCopyAction.MOVE, true))
}
// Wait for all promises to settle
diff --git a/apps/files/src/services/MoveOrCopyService.ts b/apps/files/src/services/MoveOrCopyService.ts
new file mode 100644
index 00000000000..0cdc4544dcd
--- /dev/null
+++ b/apps/files/src/services/MoveOrCopyService.ts
@@ -0,0 +1,201 @@
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import type { Folder, Node } from '@nextcloud/files'
+import type { FileStat, ResponseDataDetailed } from 'webdav'
+
+import { isAxiosError } from '@nextcloud/axios'
+import { showInfo, showWarning, TOAST_PERMANENT_TIMEOUT } from '@nextcloud/dialogs'
+import { emit } from '@nextcloud/event-bus'
+import {
+ davGetClient,
+ davGetDefaultPropfind,
+ davResultToNode,
+ davRootPath,
+ FileType,
+ getUniqueName,
+ NodeStatus,
+} from '@nextcloud/files'
+import { t } from '@nextcloud/l10n'
+import { hasConflict, openConflictPicker } from '@nextcloud/upload'
+import { join } from 'path'
+import { getContents } from './Files'
+import { MoveCopyAction } from '../types'
+import PQueue from 'p-queue'
+import Vue from 'vue'
+import logger from '../logger'
+
+// This is the processing queue. We only want to allow 3 concurrent requests
+let queue: PQueue
+
+// Maximum number of concurrent operations
+const MAX_CONCURRENCY = 5
+
+/**
+ * Get the processing queue
+ */
+function getQueue() {
+ if (!queue) {
+ queue = new PQueue({ concurrency: MAX_CONCURRENCY })
+ }
+ return queue
+}
+
+/**
+ * Handle the copy/move of a node to a destination
+ * This can be imported and used by other scripts/components on server
+ * @param {Node} node The node to copy/move
+ * @param {Folder} destination The destination to copy/move the node to
+ * @param {MoveCopyAction} method The method to use for the copy/move
+ * @param {boolean} overwrite Whether to overwrite the destination if it exists
+ * @return A promise that resolves when the copy/move is done
+ */
+export async function handleCopyMoveNode(
+ node: Node,
+ destination: Folder,
+ method: MoveCopyAction.COPY | MoveCopyAction.MOVE,
+ overwrite = false,
+): Promise<void> {
+ if (!destination) {
+ throw new SyntaxError()
+ }
+
+ if (destination.type !== FileType.Folder) {
+ throw new Error(t('files', 'Destination is not a folder'))
+ }
+
+ // Do not allow to MOVE a node to the same folder it is already located
+ if (method === MoveCopyAction.MOVE && node.dirname === destination.path) {
+ throw new Error(t('files', 'This file/folder is already in that directory'))
+ }
+
+ /**
+ * Example:
+ * - node: /foo/bar/file.txt -> path = /foo/bar/file.txt, destination: /foo
+ * Allow move of /foo does not start with /foo/bar/file.txt so allow
+ * - node: /foo , destination: /foo/bar
+ * Do not allow as it would copy foo within itself
+ * - node: /foo/bar.txt, destination: /foo
+ * Allow copy a file to the same directory
+ * - node: "/foo/bar", destination: "/foo/bar 1"
+ * Allow to move or copy but we need to check with trailing / otherwise it would report false positive
+ */
+ if (`${destination.path}/`.startsWith(`${node.path}/`)) {
+ throw new Error(t('files', 'You cannot move a file/folder onto itself or into a subfolder of itself'))
+ }
+
+ // Set loading state
+ Vue.set(node, 'status', NodeStatus.LOADING)
+ const actionFinished = createLoadingNotification(method, node.basename, destination.path)
+
+ const queue = getQueue()
+ return await queue.add(async () => {
+ const copySuffix = (index: number) => {
+ if (index === 1) {
+ return t('files', '(copy)') // TRANSLATORS: Mark a file as a copy of another file
+ }
+ return t('files', '(copy %n)', undefined, index) // TRANSLATORS: Meaning it is the n'th copy of a file
+ }
+
+ try {
+ const client = davGetClient()
+ const currentPath = join(davRootPath, node.path)
+ const destinationPath = join(davRootPath, destination.path)
+
+ if (method === MoveCopyAction.COPY) {
+ let target = node.basename
+ // If we do not allow overwriting then find an unique name
+ if (!overwrite) {
+ const otherNodes = await client.getDirectoryContents(destinationPath) as FileStat[]
+ target = getUniqueName(
+ node.basename,
+ otherNodes.map((n) => n.basename),
+ {
+ suffix: copySuffix,
+ ignoreFileExtension: node.type === FileType.Folder,
+ },
+ )
+ }
+ await client.copyFile(currentPath, join(destinationPath, target))
+ // If the node is copied into current directory the view needs to be updated
+ if (node.dirname === destination.path) {
+ const { data } = await client.stat(
+ join(destinationPath, target),
+ {
+ details: true,
+ data: davGetDefaultPropfind(),
+ },
+ ) as ResponseDataDetailed<FileStat>
+ emit('files:node:created', davResultToNode(data))
+ }
+ } else {
+ // show conflict file popup if we do not allow overwriting
+ if (!overwrite) {
+ const otherNodes = await getContents(destination.path)
+ if (hasConflict([node], otherNodes.contents)) {
+ try {
+ // Let the user choose what to do with the conflicting files
+ const { selected, renamed } = await openConflictPicker(destination.path, [node], otherNodes.contents)
+ // two empty arrays: either only old files or conflict skipped -> no action required
+ if (!selected.length && !renamed.length) {
+ return
+ }
+ } catch (error) {
+ // User cancelled
+ showWarning(t('files', 'Move cancelled'))
+ return
+ }
+ }
+ }
+ // getting here means either no conflict, file was renamed to keep both files
+ // in a conflict, or the selected file was chosen to be kept during the conflict
+ await client.moveFile(currentPath, join(destinationPath, node.basename))
+ // Delete the node as it will be fetched again
+ // when navigating to the destination folder
+ emit('files:node:deleted', node)
+ }
+ } catch (error) {
+ if (isAxiosError(error)) {
+ if (error.response?.status === 412) {
+ throw new Error(t('files', 'A file or folder with that name already exists in this folder'))
+ } else if (error.response?.status === 423) {
+ throw new Error(t('files', 'The files are locked'))
+ } else if (error.response?.status === 404) {
+ throw new Error(t('files', 'The file does not exist anymore'))
+ } else if (error.message) {
+ throw new Error(error.message)
+ }
+ }
+ logger.debug(error as Error)
+ throw new Error()
+ } finally {
+ Vue.set(node, 'status', '')
+ actionFinished()
+ }
+ })
+}
+
+/**
+ * Create a loading notification toast
+ * @param mode The move or copy mode
+ * @param source Name of the node that is copied / moved
+ * @param destination Destination path
+ * @return {() => void} Function to hide the notification
+ */
+function createLoadingNotification(mode: MoveCopyAction, source: string, destination: string): () => void {
+ const text = mode === MoveCopyAction.MOVE
+ ? t('files', 'Moving "{source}" to "{destination}" …', { source, destination })
+ : t('files', 'Copying "{source}" to "{destination}" …', { source, destination })
+
+ let toast: ReturnType<typeof showInfo>|undefined
+ toast = showInfo(
+ `<span class="icon icon-loading-small toast-loading-icon"></span> ${text}`,
+ {
+ isHTML: true,
+ timeout: TOAST_PERMANENT_TIMEOUT,
+ onRemove: () => { toast?.hideToast(); toast = undefined },
+ },
+ )
+ return () => toast && toast.hideToast()
+}
diff --git a/apps/files/src/types.ts b/apps/files/src/types.ts
index 673cb06e182..df0835d5126 100644
--- a/apps/files/src/types.ts
+++ b/apps/files/src/types.ts
@@ -128,3 +128,14 @@ export type Capabilities = {
versioning: boolean
}
}
+
+export enum MoveCopyAction {
+ MOVE = 'Move',
+ COPY = 'Copy',
+ MOVE_OR_COPY = 'move-or-copy',
+}
+
+export type MoveCopyResult = {
+ destination: Folder
+ action: MoveCopyAction.COPY | MoveCopyAction.MOVE
+}
diff --git a/apps/files/src/utils/filePermissions.spec.ts b/apps/files/src/utils/filePermissions.spec.ts
new file mode 100644
index 00000000000..0a44797a016
--- /dev/null
+++ b/apps/files/src/utils/filePermissions.spec.ts
@@ -0,0 +1,199 @@
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import type { NodeData } from '@nextcloud/files'
+
+import { File, Permission } from '@nextcloud/files'
+import { beforeEach, describe, expect, test, vi } from 'vitest'
+import { canCopy, canDownload, canMove } from './filePermissions'
+
+import * as nextcloudSharing from '@nextcloud/sharing/public'
+import * as nextcloudInitialState from '@nextcloud/initial-state'
+
+vi.mock('@nextcloud/initial-state')
+vi.mock('@nextcloud/sharing/public')
+
+/**
+ * Helper to create File instances for tests
+ * @param data Optional overwrites for node data
+ * @param name Optional overwrite filename
+ */
+function createFile(data: Partial<NodeData> = {}, name = 'file.txt'): File {
+ return new File({
+ mime: 'text/plain',
+ owner: 'test',
+ source: `http://nextcloud.local/remote.php/dav/files/admin/test/${name}`,
+ root: '/files/admin',
+ ...data,
+ })
+}
+
+describe('canDownload', () => {
+
+ test('no download restrictions - no share attributes', () => {
+ const file = createFile()
+ expect(canDownload(file)).toBe(true)
+ })
+
+ test('no download restrictions - with random share attributes', () => {
+ const file = createFile({
+ attributes: {
+ 'share-attributes': '[{"scope": "foo","value":true}]',
+ },
+ })
+ expect(canDownload(file)).toBe(true)
+ })
+
+ test('no download restrictions - with share attributes', () => {
+ const file = createFile({
+ attributes: {
+ 'share-attributes': '[{"scope": "permissions","key":"download","value":true}]',
+ },
+ })
+ expect(canDownload(file)).toBe(true)
+ })
+
+ test('download restricted', () => {
+ const file = createFile({
+ attributes: {
+ 'share-attributes': '[{"scope": "permissions","key":"download","value":false}]',
+ },
+ })
+ expect(canDownload(file)).toBe(false)
+ })
+
+ test('Empty attributes', () => {
+ const file = createFile({
+ attributes: {
+ 'share-attributes': '',
+ },
+ })
+ expect(canDownload(file)).toBe(true)
+ })
+
+ test('no download restrictions - multiple files', () => {
+ expect(
+ canDownload([createFile(), createFile({}, 'other.txt')]),
+ ).toBe(true)
+ })
+
+ test('with some download restrictions - multiple files', () => {
+ const file = createFile()
+ const restricted = createFile({
+ attributes: {
+ 'share-attributes': '[{"scope": "permissions","key":"download","value":false}]',
+ },
+ })
+ expect(canDownload([file, restricted])).toBe(false)
+ })
+
+})
+
+describe('canMove', () => {
+
+ test('All permissions', () => {
+ const file = createFile({
+ permissions: Permission.ALL,
+ })
+ expect(canMove(file)).toBe(true)
+ })
+
+ test('Read + Delete permissions', () => {
+ const file = createFile({
+ permissions: Permission.READ | Permission.DELETE,
+ })
+ expect(canMove(file)).toBe(true)
+ })
+
+ test('Only read permissions', () => {
+ const file = createFile({
+ permissions: Permission.READ,
+ })
+ expect(canMove(file)).toBe(false)
+ })
+
+ test('Missing permissions', () => {
+ const file = createFile()
+ expect(canMove(file)).toBe(false)
+ })
+
+ test('Multiple files with permissions', () => {
+ const file = createFile({ permissions: Permission.ALL })
+ const file2 = createFile({ permissions: Permission.READ | Permission.DELETE })
+ expect(canMove([file, file2])).toBe(true)
+ })
+
+ test('Multiple files without permissions', () => {
+ const file = createFile({ permissions: Permission.ALL })
+ const file2 = createFile({ permissions: Permission.READ | Permission.DELETE })
+ const file3 = createFile({ permissions: Permission.READ | Permission.UPDATE })
+ expect(canMove([file, file2, file3])).toBe(false)
+ })
+
+})
+
+describe('canCopy', () => {
+
+ beforeEach(() => {
+ vi.restoreAllMocks()
+ })
+
+ test('No permissions', () => {
+ const file = createFile({
+ permissions: Permission.NONE,
+ })
+ expect(canCopy(file)).toBe(false)
+ })
+
+ test('All permissions', () => {
+ const file = createFile({
+ permissions: Permission.ALL,
+ })
+ expect(canCopy(file)).toBe(true)
+ })
+
+ test('Read permissions', () => {
+ const file = createFile({
+ permissions: Permission.READ,
+ })
+ expect(canCopy(file)).toBe(true)
+ })
+
+ test('All permissions but no download', () => {
+ const file = createFile({
+ permissions: Permission.ALL,
+ attributes: {
+ 'share-attributes': '[{"scope": "permissions","key":"download","value":false}]',
+ },
+ })
+ expect(canCopy(file)).toBe(false)
+ })
+
+ test('Public share but no create permission', () => {
+ vi.spyOn(nextcloudInitialState, 'loadState')
+ .mockImplementationOnce(() => Permission.READ)
+ vi.spyOn(nextcloudSharing, 'isPublicShare').mockImplementationOnce(() => true)
+
+ const file = createFile({
+ permissions: Permission.READ | Permission.UPDATE | Permission.DELETE,
+ })
+ expect(canCopy(file)).toBe(false)
+ })
+
+ test('Public share with permissions', async () => {
+ // Reset modules so we can change the initial-state
+ vi.resetModules()
+ const { canCopy } = await import('./filePermissions.ts')
+
+ vi.spyOn(nextcloudInitialState, 'loadState')
+ .mockImplementationOnce(() => (Permission.READ | Permission.CREATE))
+ vi.spyOn(nextcloudSharing, 'isPublicShare').mockImplementationOnce(() => true)
+
+ const file = createFile({
+ permissions: Permission.READ,
+ })
+ expect(canCopy(file)).toBe(true)
+ })
+})
diff --git a/apps/files/src/actions/moveOrCopyActionUtils.ts b/apps/files/src/utils/filePermissions.ts
index 0372e8f4bc7..e845499b52d 100644
--- a/apps/files/src/actions/moveOrCopyActionUtils.ts
+++ b/apps/files/src/utils/filePermissions.ts
@@ -3,65 +3,52 @@
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
-import type { Folder, Node } from '@nextcloud/files'
+import type { INode } from '@nextcloud/files'
import type { ShareAttribute } from '../../../files_sharing/src/sharing'
import { Permission } from '@nextcloud/files'
import { isPublicShare } from '@nextcloud/sharing/public'
-import PQueue from 'p-queue'
import { loadState } from '@nextcloud/initial-state'
-const sharePermissions = loadState<number>('files_sharing', 'sharePermissions', Permission.NONE)
-
-// This is the processing queue. We only want to allow 3 concurrent requests
-let queue: PQueue
-
-// Maximum number of concurrent operations
-const MAX_CONCURRENCY = 5
-
/**
- * Get the processing queue
+ * Check if the node can be download
+ * @param nodes one or multiple nodes to check
*/
-export const getQueue = () => {
- if (!queue) {
- queue = new PQueue({ concurrency: MAX_CONCURRENCY })
- }
- return queue
-}
-
-export enum MoveCopyAction {
- MOVE = 'Move',
- COPY = 'Copy',
- MOVE_OR_COPY = 'move-or-copy',
-}
+export function canDownload(nodes: INode | INode[]): boolean {
+ return [nodes].flat().every(node => {
+ const shareAttributes = JSON.parse(node.attributes?.['share-attributes'] || '[]') as Array<ShareAttribute>
+ return !shareAttributes.some(attribute => attribute.scope === 'permissions' && attribute.value === false && attribute.key === 'download')
-export type MoveCopyResult = {
- destination: Folder
- action: MoveCopyAction.COPY | MoveCopyAction.MOVE
+ })
}
-export const canMove = (nodes: Node[]) => {
- const minPermission = nodes.reduce((min, node) => Math.min(min, node.permissions), Permission.ALL)
+/**
+ * Check if the node can be moved
+ * @param nodes one or multiple nodes to check
+ */
+export function canMove(nodes: INode | INode[]): boolean {
+ const minPermission = [nodes].flat()
+ .reduce((min, node) => Math.min(min, node.permissions), Permission.ALL)
return Boolean(minPermission & Permission.DELETE)
}
-export const canDownload = (nodes: Node[]) => {
- return nodes.every(node => {
- const shareAttributes = JSON.parse(node.attributes?.['share-attributes'] ?? '[]') as Array<ShareAttribute>
- return !shareAttributes.some(attribute => attribute.scope === 'permissions' && attribute.value === false && attribute.key === 'download')
-
- })
-}
+// On public shares we need to consider the top level permission
+const sharePermissions = loadState<number>('files_sharing', 'sharePermissions', Permission.NONE)
-export const canCopy = (nodes: Node[]) => {
+/**
+ * Check if the node can be copied
+ * @param nodes one or multiple nodes to check
+ */
+export function canCopy(nodes: INode | INode[]): boolean {
// a shared file cannot be copied if the download is disabled
if (!canDownload(nodes)) {
return false
}
// it cannot be copied if the user has only view permissions
- if (nodes.some((node) => node.permissions === Permission.NONE)) {
+ if ([nodes].flat().some((node) => node.permissions === Permission.NONE)) {
return false
}
+
// on public shares all files have the same permission so copy is only possible if write permission is granted
if (isPublicShare()) {
return Boolean(sharePermissions & Permission.CREATE)
diff --git a/vitest.config.ts b/vitest.config.ts
index cdf322223bd..dc280b6f684 100644
--- a/vitest.config.ts
+++ b/vitest.config.ts
@@ -17,7 +17,12 @@ export default defineConfig({
},
coverage: {
include: ['apps/*/src/**', 'core/src/**'],
- exclude: ['**.spec.*', '**.test.*', '**.cy.*', 'core/src/tests/**'],
+ exclude: [
+ '**/*.spec.*',
+ '**/*.test.*',
+ '**/*.cy.*',
+ 'core/src/tests/**',
+ ],
provider: 'v8',
reporter: ['lcov', 'text'],
},