diff options
author | Ferdinand Thiessen <opensource@fthiessen.de> | 2024-11-15 01:49:44 +0100 |
---|---|---|
committer | Ferdinand Thiessen <opensource@fthiessen.de> | 2025-01-15 16:54:47 +0100 |
commit | 2fb8a033038efa19c461dacad57cc00c6d039db1 (patch) | |
tree | c77d3cc61a23a3030ddc7a586e6bd6d4d44ffc18 | |
parent | 47eedf9228efbf31136a1cc0c2efa747cada596d (diff) | |
download | nextcloud-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.ts | 8 | ||||
-rw-r--r-- | apps/files/src/actions/moveOrCopyAction.ts | 184 | ||||
-rw-r--r-- | apps/files/src/services/DropService.ts | 6 | ||||
-rw-r--r-- | apps/files/src/services/MoveOrCopyService.ts | 201 | ||||
-rw-r--r-- | apps/files/src/types.ts | 11 | ||||
-rw-r--r-- | apps/files/src/utils/filePermissions.spec.ts | 199 | ||||
-rw-r--r-- | apps/files/src/utils/filePermissions.ts (renamed from apps/files/src/actions/moveOrCopyActionUtils.ts) | 61 | ||||
-rw-r--r-- | vitest.config.ts | 7 |
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'], }, |