diff options
Diffstat (limited to 'apps/files_sharing/src/files_actions')
10 files changed, 1113 insertions, 0 deletions
diff --git a/apps/files_sharing/src/files_actions/acceptShareAction.spec.ts b/apps/files_sharing/src/files_actions/acceptShareAction.spec.ts new file mode 100644 index 00000000000..4003e0799ac --- /dev/null +++ b/apps/files_sharing/src/files_actions/acceptShareAction.spec.ts @@ -0,0 +1,217 @@ +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import { beforeAll, beforeEach, describe, expect, test, vi } from 'vitest' + +import { action } from './acceptShareAction' +import { File, Permission, View, FileAction } from '@nextcloud/files' +import { ShareType } from '@nextcloud/sharing' +import * as eventBus from '@nextcloud/event-bus' +import axios from '@nextcloud/axios' + +import '../main.ts' + +vi.mock('@nextcloud/axios') + +const view = { + id: 'files', + name: 'Files', +} as View + +const pendingShareView = { + id: 'pendingshares', + name: 'Pending shares', +} as View + +// Mock webroot variable +beforeAll(() => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (window as any)._oc_webroot = '' +}) + +describe('Accept share action conditions tests', () => { + test('Default values', () => { + 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.ALL, + }) + + expect(action).toBeInstanceOf(FileAction) + expect(action.id).toBe('accept-share') + expect(action.displayName([file], pendingShareView)).toBe('Accept share') + expect(action.iconSvgInline([file], pendingShareView)).toMatch(/<svg.+<\/svg>/) + expect(action.default).toBeUndefined() + expect(action.order).toBe(1) + expect(action.inline).toBeDefined() + expect(action.inline!(file, pendingShareView)).toBe(true) + }) + + test('Default values for multiple files', () => { + const file1 = new File({ + id: 1, + source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt', + owner: 'admin', + mime: 'text/plain', + permissions: Permission.ALL, + }) + const file2 = new File({ + id: 2, + source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt', + owner: 'admin', + mime: 'text/plain', + permissions: Permission.ALL, + }) + + expect(action.displayName([file1, file2], pendingShareView)).toBe('Accept shares') + }) +}) + +describe('Accept share action enabled tests', () => { + test('Enabled with on pending shares view', () => { + 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.ALL, + }) + + expect(action.enabled).toBeDefined() + expect(action.enabled!([file], pendingShareView)).toBe(true) + }) + + test('Disabled on wrong view', () => { + expect(action.enabled).toBeDefined() + expect(action.enabled!([], view)).toBe(false) + }) + + test('Disabled without nodes', () => { + expect(action.enabled).toBeDefined() + expect(action.enabled!([], pendingShareView)).toBe(false) + }) +}) + +describe('Accept share action execute tests', () => { + beforeEach(() => { vi.resetAllMocks() }) + + test('Accept share action', async () => { + vi.spyOn(axios, 'post') + vi.spyOn(eventBus, 'emit') + + 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, + attributes: { + id: 123, + share_type: ShareType.User, + }, + }) + + const exec = await action.exec(file, pendingShareView, '/') + + expect(exec).toBe(true) + expect(axios.post).toBeCalledTimes(1) + expect(axios.post).toBeCalledWith('http://nextcloud.local/ocs/v2.php/apps/files_sharing/api/v1/shares/pending/123') + + expect(eventBus.emit).toBeCalledTimes(1) + expect(eventBus.emit).toBeCalledWith('files:node:deleted', file) + }) + + test('Accept remote share action', async () => { + vi.spyOn(axios, 'post') + vi.spyOn(eventBus, 'emit') + + 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, + attributes: { + id: 123, + remote: 3, + share_type: ShareType.User, + }, + }) + + const exec = await action.exec(file, pendingShareView, '/') + + expect(exec).toBe(true) + expect(axios.post).toBeCalledTimes(1) + expect(axios.post).toBeCalledWith('http://nextcloud.local/ocs/v2.php/apps/files_sharing/api/v1/remote_shares/pending/123') + + expect(eventBus.emit).toBeCalledTimes(1) + expect(eventBus.emit).toBeCalledWith('files:node:deleted', file) + }) + + test('Accept share action batch', async () => { + vi.spyOn(axios, 'post') + vi.spyOn(eventBus, 'emit') + + 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, + attributes: { + id: 123, + share_type: ShareType.User, + }, + }) + + 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, + attributes: { + id: 456, + share_type: ShareType.User, + }, + }) + + const exec = await action.execBatch!([file1, file2], pendingShareView, '/') + + expect(exec).toStrictEqual([true, true]) + expect(axios.post).toBeCalledTimes(2) + expect(axios.post).toHaveBeenNthCalledWith(1, 'http://nextcloud.local/ocs/v2.php/apps/files_sharing/api/v1/shares/pending/123') + expect(axios.post).toHaveBeenNthCalledWith(2, 'http://nextcloud.local/ocs/v2.php/apps/files_sharing/api/v1/shares/pending/456') + + expect(eventBus.emit).toBeCalledTimes(2) + expect(eventBus.emit).toHaveBeenNthCalledWith(1, 'files:node:deleted', file1) + expect(eventBus.emit).toHaveBeenNthCalledWith(2, 'files:node:deleted', file2) + }) + + test('Accept fails', async () => { + vi.spyOn(axios, 'post').mockImplementation(() => { throw new Error('Mock error') }) + + 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, + attributes: { + id: 123, + share_type: ShareType.User, + }, + }) + + const exec = await action.exec(file, pendingShareView, '/') + + expect(exec).toBe(false) + expect(axios.post).toBeCalledTimes(1) + expect(axios.post).toBeCalledWith('http://nextcloud.local/ocs/v2.php/apps/files_sharing/api/v1/shares/pending/123') + + expect(eventBus.emit).toBeCalledTimes(0) + }) +}) diff --git a/apps/files_sharing/src/files_actions/acceptShareAction.ts b/apps/files_sharing/src/files_actions/acceptShareAction.ts new file mode 100644 index 00000000000..f2177fdec1a --- /dev/null +++ b/apps/files_sharing/src/files_actions/acceptShareAction.ts @@ -0,0 +1,48 @@ +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import type { Node, View } from '@nextcloud/files' + +import { emit } from '@nextcloud/event-bus' +import { generateOcsUrl } from '@nextcloud/router' +import { registerFileAction, FileAction } from '@nextcloud/files' +import { translatePlural as n } from '@nextcloud/l10n' +import axios from '@nextcloud/axios' +import CheckSvg from '@mdi/svg/svg/check.svg?raw' + +import { pendingSharesViewId } from '../files_views/shares' + +export const action = new FileAction({ + id: 'accept-share', + displayName: (nodes: Node[]) => n('files_sharing', 'Accept share', 'Accept shares', nodes.length), + iconSvgInline: () => CheckSvg, + + enabled: (nodes, view) => nodes.length > 0 && view.id === pendingSharesViewId, + + async exec(node: Node) { + try { + const isRemote = !!node.attributes.remote + const url = generateOcsUrl('apps/files_sharing/api/v1/{shareBase}/pending/{id}', { + shareBase: isRemote ? 'remote_shares' : 'shares', + id: node.attributes.id, + }) + await axios.post(url) + + // Remove from current view + emit('files:node:deleted', node) + + return true + } catch (error) { + return false + } + }, + async execBatch(nodes: Node[], view: View, dir: string) { + return Promise.all(nodes.map(node => this.exec(node, view, dir))) + }, + + order: 1, + inline: () => true, +}) + +registerFileAction(action) diff --git a/apps/files_sharing/src/files_actions/openInFilesAction.spec.ts b/apps/files_sharing/src/files_actions/openInFilesAction.spec.ts new file mode 100644 index 00000000000..23c0938545c --- /dev/null +++ b/apps/files_sharing/src/files_actions/openInFilesAction.spec.ts @@ -0,0 +1,78 @@ +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import { File, Permission, View, DefaultType, FileAction } from '@nextcloud/files' +import { describe, expect, test, vi } from 'vitest' +import { deletedSharesViewId, pendingSharesViewId, sharedWithOthersViewId, sharedWithYouViewId, sharesViewId, sharingByLinksViewId } from '../files_views/shares' +import { action } from './openInFilesAction' + +import '../main' + +const view = { + id: 'files', + name: 'Files', +} as View + +const validViews = [ + sharesViewId, + sharedWithYouViewId, + sharedWithOthersViewId, + sharingByLinksViewId, +].map(id => ({ id, name: id })) as View[] + +const invalidViews = [ + deletedSharesViewId, + pendingSharesViewId, +].map(id => ({ id, name: id })) as View[] + +describe('Open in files action conditions tests', () => { + test('Default values', () => { + expect(action).toBeInstanceOf(FileAction) + expect(action.id).toBe('files_sharing:open-in-files') + expect(action.displayName([], validViews[0])).toBe('Open in Files') + expect(action.iconSvgInline([], validViews[0])).toBe('') + expect(action.default).toBe(DefaultType.HIDDEN) + expect(action.order).toBe(-1000) + expect(action.inline).toBeUndefined() + }) +}) + +describe('Open in files action enabled tests', () => { + test('Enabled with on valid view', () => { + validViews.forEach(view => { + expect(action.enabled).toBeDefined() + expect(action.enabled!([], view)).toBe(true) + }) + }) + + test('Disabled on wrong view', () => { + invalidViews.forEach(view => { + expect(action.enabled).toBeDefined() + expect(action.enabled!([], view)).toBe(false) + }) + }) +}) + +describe('Open in files action execute tests', () => { + test('Open in files', async () => { + const goToRouteMock = vi.fn() + // @ts-expect-error We only mock what needed, we do not need Files.Router.goTo or Files.Navigation + window.OCP = { Files: { Router: { goToRoute: goToRouteMock } } } + + const file = new File({ + id: 1, + source: 'https://cloud.domain.com/remote.php/dav/files/admin/Foo/foobar.txt', + owner: 'admin', + mime: 'text/plain', + root: '/files/admin', + permissions: Permission.READ, + }) + + const exec = await action.exec(file, view, '/') + // Silent action + expect(exec).toBe(null) + expect(goToRouteMock).toBeCalledTimes(1) + expect(goToRouteMock).toBeCalledWith(null, { fileid: '1', view: 'files' }, { dir: '/Foo', openfile: 'true' }) + }) +}) diff --git a/apps/files_sharing/src/files_actions/openInFilesAction.ts b/apps/files_sharing/src/files_actions/openInFilesAction.ts new file mode 100644 index 00000000000..133b4531bb5 --- /dev/null +++ b/apps/files_sharing/src/files_actions/openInFilesAction.ts @@ -0,0 +1,50 @@ +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { Node } from '@nextcloud/files' + +import { registerFileAction, FileAction, DefaultType, FileType } from '@nextcloud/files' +import { translate as t } from '@nextcloud/l10n' +import { sharesViewId, sharedWithYouViewId, sharedWithOthersViewId, sharingByLinksViewId } from '../files_views/shares' + +export const action = new FileAction({ + id: 'files_sharing:open-in-files', + displayName: () => t('files_sharing', 'Open in Files'), + iconSvgInline: () => '', + + enabled: (nodes, view) => [ + sharesViewId, + sharedWithYouViewId, + sharedWithOthersViewId, + sharingByLinksViewId, + // Deleted and pending shares are not + // accessible in the files app. + ].includes(view.id), + + async exec(node: Node) { + const isFolder = node.type === FileType.Folder + + window.OCP.Files.Router.goToRoute( + null, // use default route + { + view: 'files', + fileid: String(node.fileid), + }, + { + // If this node is a folder open the folder in files + dir: isFolder ? node.path : node.dirname, + // otherwise if this is a file, we should open it + openfile: isFolder ? undefined : 'true', + }, + ) + return null + }, + + // Before openFolderAction + order: -1000, + default: DefaultType.HIDDEN, +}) + +registerFileAction(action) diff --git a/apps/files_sharing/src/files_actions/rejectShareAction.spec.ts b/apps/files_sharing/src/files_actions/rejectShareAction.spec.ts new file mode 100644 index 00000000000..51ded69d1c5 --- /dev/null +++ b/apps/files_sharing/src/files_actions/rejectShareAction.spec.ts @@ -0,0 +1,243 @@ +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import { File, Folder, Permission, View, FileAction } from '@nextcloud/files' +import { beforeAll, beforeEach, describe, expect, test, vi } from 'vitest' +import { ShareType } from '@nextcloud/sharing' +import * as eventBus from '@nextcloud/event-bus' +import axios from '@nextcloud/axios' + +import { action } from './rejectShareAction' +import '../main' + +vi.mock('@nextcloud/axios') + +const view = { + id: 'files', + name: 'Files', +} as View + +const pendingShareView = { + id: 'pendingshares', + name: 'Pending shares', +} as View + +// Mock webroot variable +beforeAll(() => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (window as any)._oc_webroot = '' +}) + +describe('Reject share action conditions tests', () => { + test('Default values', () => { + 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.ALL, + }) + + expect(action).toBeInstanceOf(FileAction) + expect(action.id).toBe('reject-share') + expect(action.displayName([file], pendingShareView)).toBe('Reject share') + expect(action.iconSvgInline([file], pendingShareView)).toMatch(/<svg.+<\/svg>/) + expect(action.default).toBeUndefined() + expect(action.order).toBe(2) + expect(action.inline).toBeDefined() + expect(action.inline!(file, pendingShareView)).toBe(true) + }) + + test('Default values for multiple files', () => { + const file1 = new File({ + id: 1, + source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt', + owner: 'admin', + mime: 'text/plain', + permissions: Permission.ALL, + }) + const file2 = new File({ + id: 2, + source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt', + owner: 'admin', + mime: 'text/plain', + permissions: Permission.ALL, + }) + + expect(action.displayName([file1, file2], pendingShareView)).toBe('Reject shares') + }) +}) + +describe('Reject share action enabled tests', () => { + test('Enabled with on pending shares view', () => { + 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.ALL, + }) + + expect(action.enabled).toBeDefined() + expect(action.enabled!([file], pendingShareView)).toBe(true) + }) + + test('Disabled on wrong view', () => { + expect(action.enabled).toBeDefined() + expect(action.enabled!([], view)).toBe(false) + }) + + test('Disabled without nodes', () => { + expect(action.enabled).toBeDefined() + expect(action.enabled!([], pendingShareView)).toBe(false) + }) + + test('Disabled if some nodes are remote group shares', () => { + const folder1 = new Folder({ + id: 1, + source: 'https://cloud.domain.com/remote.php/dav/files/admin/Foo/', + owner: 'admin', + permissions: Permission.READ, + attributes: { + share_type: ShareType.User, + }, + }) + const folder2 = new Folder({ + id: 2, + source: 'https://cloud.domain.com/remote.php/dav/files/admin/Bar/', + owner: 'admin', + permissions: Permission.READ, + attributes: { + remote_id: 1, + share_type: ShareType.RemoteGroup, + }, + }) + + expect(action.enabled).toBeDefined() + expect(action.enabled!([folder1], pendingShareView)).toBe(true) + expect(action.enabled!([folder2], pendingShareView)).toBe(false) + expect(action.enabled!([folder1, folder2], pendingShareView)).toBe(false) + }) +}) + +describe('Reject share action execute tests', () => { + beforeEach(() => { vi.resetAllMocks() }) + + test('Reject share action', async () => { + vi.spyOn(axios, 'delete') + vi.spyOn(eventBus, 'emit') + + 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, + attributes: { + id: 123, + share_type: ShareType.User, + }, + }) + + const exec = await action.exec(file, pendingShareView, '/') + + expect(exec).toBe(true) + expect(axios.delete).toBeCalledTimes(1) + expect(axios.delete).toBeCalledWith('http://nextcloud.local/ocs/v2.php/apps/files_sharing/api/v1/shares/123') + + expect(eventBus.emit).toBeCalledTimes(1) + expect(eventBus.emit).toBeCalledWith('files:node:deleted', file) + }) + + test('Reject remote share action', async () => { + vi.spyOn(axios, 'delete') + vi.spyOn(eventBus, 'emit') + + 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, + attributes: { + id: 123, + remote: 3, + share_type: ShareType.User, + }, + }) + + const exec = await action.exec(file, pendingShareView, '/') + + expect(exec).toBe(true) + expect(axios.delete).toBeCalledTimes(1) + expect(axios.delete).toBeCalledWith('http://nextcloud.local/ocs/v2.php/apps/files_sharing/api/v1/remote_shares/123') + + expect(eventBus.emit).toBeCalledTimes(1) + expect(eventBus.emit).toBeCalledWith('files:node:deleted', file) + }) + + test('Reject share action batch', async () => { + vi.spyOn(axios, 'delete') + vi.spyOn(eventBus, 'emit') + + 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, + attributes: { + id: 123, + share_type: ShareType.User, + }, + }) + + 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, + attributes: { + id: 456, + share_type: ShareType.User, + }, + }) + + const exec = await action.execBatch!([file1, file2], pendingShareView, '/') + + expect(exec).toStrictEqual([true, true]) + expect(axios.delete).toBeCalledTimes(2) + expect(axios.delete).toHaveBeenNthCalledWith(1, 'http://nextcloud.local/ocs/v2.php/apps/files_sharing/api/v1/shares/123') + expect(axios.delete).toHaveBeenNthCalledWith(2, 'http://nextcloud.local/ocs/v2.php/apps/files_sharing/api/v1/shares/456') + + expect(eventBus.emit).toBeCalledTimes(2) + expect(eventBus.emit).toHaveBeenNthCalledWith(1, 'files:node:deleted', file1) + expect(eventBus.emit).toHaveBeenNthCalledWith(2, 'files:node:deleted', file2) + }) + + test('Reject fails', async () => { + vi.spyOn(axios, 'delete').mockImplementation(() => { throw new Error('Mock error') }) + + 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, + attributes: { + id: 123, + share_type: ShareType.User, + }, + }) + + const exec = await action.exec(file, pendingShareView, '/') + + expect(exec).toBe(false) + expect(axios.delete).toBeCalledTimes(1) + expect(axios.delete).toBeCalledWith('http://nextcloud.local/ocs/v2.php/apps/files_sharing/api/v1/shares/123') + + expect(eventBus.emit).toBeCalledTimes(0) + }) +}) diff --git a/apps/files_sharing/src/files_actions/rejectShareAction.ts b/apps/files_sharing/src/files_actions/rejectShareAction.ts new file mode 100644 index 00000000000..22f77262ef2 --- /dev/null +++ b/apps/files_sharing/src/files_actions/rejectShareAction.ts @@ -0,0 +1,66 @@ +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import type { Node, View } from '@nextcloud/files' + +import { emit } from '@nextcloud/event-bus' +import { generateOcsUrl } from '@nextcloud/router' +import { registerFileAction, FileAction } from '@nextcloud/files' +import { translatePlural as n } from '@nextcloud/l10n' +import { ShareType } from '@nextcloud/sharing' +import { pendingSharesViewId } from '../files_views/shares' + +import axios from '@nextcloud/axios' +import CloseSvg from '@mdi/svg/svg/close.svg?raw' + +export const action = new FileAction({ + id: 'reject-share', + displayName: (nodes: Node[]) => n('files_sharing', 'Reject share', 'Reject shares', nodes.length), + iconSvgInline: () => CloseSvg, + + enabled: (nodes, view) => { + if (view.id !== pendingSharesViewId) { + return false + } + + if (nodes.length === 0) { + return false + } + + // disable rejecting group shares from the pending list because they anyway + // land back into that same list after rejecting them + if (nodes.some(node => node.attributes.remote_id + && node.attributes.share_type === ShareType.RemoteGroup)) { + return false + } + + return true + }, + + async exec(node: Node) { + try { + const isRemote = !!node.attributes.remote + const url = generateOcsUrl('apps/files_sharing/api/v1/{shareBase}/{id}', { + shareBase: isRemote ? 'remote_shares' : 'shares', + id: node.attributes.id, + }) + await axios.delete(url) + + // Remove from current view + emit('files:node:deleted', node) + + return true + } catch (error) { + return false + } + }, + async execBatch(nodes: Node[], view: View, dir: string) { + return Promise.all(nodes.map(node => this.exec(node, view, dir))) + }, + + order: 2, + inline: () => true, +}) + +registerFileAction(action) diff --git a/apps/files_sharing/src/files_actions/restoreShareAction.spec.ts b/apps/files_sharing/src/files_actions/restoreShareAction.spec.ts new file mode 100644 index 00000000000..015aa8aa95d --- /dev/null +++ b/apps/files_sharing/src/files_actions/restoreShareAction.spec.ts @@ -0,0 +1,191 @@ +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import { File, Permission, View, FileAction } from '@nextcloud/files' +import { ShareType } from '@nextcloud/sharing' +import { beforeAll, beforeEach, describe, expect, test, vi } from 'vitest' + +import axios from '@nextcloud/axios' +import * as eventBus from '@nextcloud/event-bus' +import { action } from './restoreShareAction' +import '../main.ts' + +vi.mock('@nextcloud/auth') +vi.mock('@nextcloud/axios') + +const view = { + id: 'files', + name: 'Files', +} as View + +const deletedShareView = { + id: 'deletedshares', + name: 'Deleted shares', +} as View + +// Mock webroot variable +beforeAll(() => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (window as any)._oc_webroot = '' +}) + +describe('Restore share action conditions tests', () => { + test('Default values', () => { + 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.ALL, + }) + + expect(action).toBeInstanceOf(FileAction) + expect(action.id).toBe('restore-share') + expect(action.displayName([file], deletedShareView)).toBe('Restore share') + expect(action.iconSvgInline([file], deletedShareView)).toMatch(/<svg.+<\/svg>/) + expect(action.default).toBeUndefined() + expect(action.order).toBe(1) + expect(action.inline).toBeDefined() + expect(action.inline!(file, deletedShareView)).toBe(true) + }) + + test('Default values for multiple files', () => { + const file1 = new File({ + id: 1, + source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt', + owner: 'admin', + mime: 'text/plain', + permissions: Permission.ALL, + }) + const file2 = new File({ + id: 2, + source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt', + owner: 'admin', + mime: 'text/plain', + permissions: Permission.ALL, + }) + + expect(action.displayName([file1, file2], deletedShareView)).toBe('Restore shares') + }) +}) + +describe('Restore share action enabled tests', () => { + test('Enabled with on pending shares view', () => { + 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.ALL, + }) + + expect(action.enabled).toBeDefined() + expect(action.enabled!([file], deletedShareView)).toBe(true) + }) + + test('Disabled on wrong view', () => { + expect(action.enabled).toBeDefined() + expect(action.enabled!([], view)).toBe(false) + }) + + test('Disabled without nodes', () => { + expect(action.enabled).toBeDefined() + expect(action.enabled!([], deletedShareView)).toBe(false) + }) +}) + +describe('Restore share action execute tests', () => { + beforeEach(() => { vi.resetAllMocks() }) + + test('Restore share action', async () => { + vi.spyOn(axios, 'post') + vi.spyOn(eventBus, 'emit') + + 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, + attributes: { + id: 123, + share_type: ShareType.User, + }, + }) + + const exec = await action.exec(file, deletedShareView, '/') + + expect(exec).toBe(true) + expect(axios.post).toBeCalledTimes(1) + expect(axios.post).toBeCalledWith('http://nextcloud.local/ocs/v2.php/apps/files_sharing/api/v1/deletedshares/123') + + expect(eventBus.emit).toBeCalledTimes(1) + expect(eventBus.emit).toBeCalledWith('files:node:deleted', file) + }) + + test('Restore share action batch', async () => { + vi.spyOn(axios, 'post') + vi.spyOn(eventBus, 'emit') + + 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, + attributes: { + id: 123, + share_type: ShareType.User, + }, + }) + + 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, + attributes: { + id: 456, + share_type: ShareType.User, + }, + }) + + const exec = await action.execBatch!([file1, file2], deletedShareView, '/') + + expect(exec).toStrictEqual([true, true]) + expect(axios.post).toBeCalledTimes(2) + expect(axios.post).toHaveBeenNthCalledWith(1, 'http://nextcloud.local/ocs/v2.php/apps/files_sharing/api/v1/deletedshares/123') + expect(axios.post).toHaveBeenNthCalledWith(2, 'http://nextcloud.local/ocs/v2.php/apps/files_sharing/api/v1/deletedshares/456') + + expect(eventBus.emit).toBeCalledTimes(2) + expect(eventBus.emit).toHaveBeenNthCalledWith(1, 'files:node:deleted', file1) + expect(eventBus.emit).toHaveBeenNthCalledWith(2, 'files:node:deleted', file2) + }) + + test('Restore fails', async () => { + vi.spyOn(axios, 'post') + .mockImplementation(() => { throw new Error('Mock error') }) + + 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, + attributes: { + id: 123, + share_type: ShareType.User, + }, + }) + + const exec = await action.exec(file, deletedShareView, '/') + + expect(exec).toBe(false) + expect(axios.post).toBeCalledTimes(1) + expect(axios.post).toBeCalledWith('http://nextcloud.local/ocs/v2.php/apps/files_sharing/api/v1/deletedshares/123') + + expect(eventBus.emit).toBeCalledTimes(0) + }) +}) diff --git a/apps/files_sharing/src/files_actions/restoreShareAction.ts b/apps/files_sharing/src/files_actions/restoreShareAction.ts new file mode 100644 index 00000000000..2d51de387ee --- /dev/null +++ b/apps/files_sharing/src/files_actions/restoreShareAction.ts @@ -0,0 +1,47 @@ +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import type { Node, View } from '@nextcloud/files' + +import { emit } from '@nextcloud/event-bus' +import { FileAction, registerFileAction } from '@nextcloud/files' +import { generateOcsUrl } from '@nextcloud/router' +import { translatePlural as n } from '@nextcloud/l10n' +import ArrowULeftTopSvg from '@mdi/svg/svg/arrow-u-left-top.svg?raw' +import axios from '@nextcloud/axios' + +import { deletedSharesViewId } from '../files_views/shares' + +export const action = new FileAction({ + id: 'restore-share', + displayName: (nodes: Node[]) => n('files_sharing', 'Restore share', 'Restore shares', nodes.length), + + iconSvgInline: () => ArrowULeftTopSvg, + + enabled: (nodes, view) => nodes.length > 0 && view.id === deletedSharesViewId, + + async exec(node: Node) { + try { + const url = generateOcsUrl('apps/files_sharing/api/v1/deletedshares/{id}', { + id: node.attributes.id, + }) + await axios.post(url) + + // Remove from current view + emit('files:node:deleted', node) + + return true + } catch (error) { + return false + } + }, + async execBatch(nodes: Node[], view: View, dir: string) { + return Promise.all(nodes.map(node => this.exec(node, view, dir))) + }, + + order: 1, + inline: () => true, +}) + +registerFileAction(action) diff --git a/apps/files_sharing/src/files_actions/sharingStatusAction.scss b/apps/files_sharing/src/files_actions/sharingStatusAction.scss new file mode 100644 index 00000000000..3a6690f40f1 --- /dev/null +++ b/apps/files_sharing/src/files_actions/sharingStatusAction.scss @@ -0,0 +1,29 @@ +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + // Only when rendered inline, when not enough space, this is put in the menu +.action-items > .files-list__row-action-sharing-status { + // put icon at the end of the button + direction: rtl; + // align icons with text-less inline actions + padding-inline-end: 0 !important; +} + +svg.sharing-status__avatar { + height: 32px !important; + width: 32px !important; + max-height: 32px !important; + max-width: 32px !important; + border-radius: 32px; + overflow: hidden; +} + +.files-list__row-action-sharing-status { + .button-vue__text { + color: var(--color-primary-element); + } + .button-vue__icon { + color: var(--color-primary-element); + } +} diff --git a/apps/files_sharing/src/files_actions/sharingStatusAction.ts b/apps/files_sharing/src/files_actions/sharingStatusAction.ts new file mode 100644 index 00000000000..18fa46d2781 --- /dev/null +++ b/apps/files_sharing/src/files_actions/sharingStatusAction.ts @@ -0,0 +1,144 @@ +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import { getCurrentUser } from '@nextcloud/auth' +import { Node, View, registerFileAction, FileAction, Permission } from '@nextcloud/files' +import { translate as t } from '@nextcloud/l10n' +import { ShareType } from '@nextcloud/sharing' +import { isPublicShare } from '@nextcloud/sharing/public' + +import AccountGroupSvg from '@mdi/svg/svg/account-group-outline.svg?raw' +import AccountPlusSvg from '@mdi/svg/svg/account-plus-outline.svg?raw' +import LinkSvg from '@mdi/svg/svg/link.svg?raw' +import CircleSvg from '../../../../core/img/apps/circles.svg?raw' + +import { action as sidebarAction } from '../../../files/src/actions/sidebarAction' +import { generateAvatarSvg } from '../utils/AccountIcon' + +import './sharingStatusAction.scss' + +const isExternal = (node: Node) => { + return node.attributes?.['is-federated'] ?? false +} + +export const ACTION_SHARING_STATUS = 'sharing-status' +export const action = new FileAction({ + id: ACTION_SHARING_STATUS, + displayName(nodes: Node[]) { + const node = nodes[0] + const shareTypes = Object.values(node?.attributes?.['share-types'] || {}).flat() as number[] + + if (shareTypes.length > 0 + || (node.owner !== getCurrentUser()?.uid || isExternal(node))) { + return t('files_sharing', 'Shared') + } + + return '' + }, + + title(nodes: Node[]) { + const node = nodes[0] + + if (node.owner && (node.owner !== getCurrentUser()?.uid || isExternal(node))) { + const ownerDisplayName = node?.attributes?.['owner-display-name'] + return t('files_sharing', 'Shared by {ownerDisplayName}', { ownerDisplayName }) + } + + const shareTypes = Object.values(node?.attributes?.['share-types'] || {}).flat() as number[] + if (shareTypes.length > 1) { + return t('files_sharing', 'Shared multiple times with different people') + } + + const sharees = node.attributes.sharees?.sharee as { id: string, 'display-name': string, type: ShareType }[] | undefined + if (!sharees) { + // No sharees so just show the default message to create a new share + return t('files_sharing', 'Sharing options') + } + + const sharee = [sharees].flat()[0] // the property is sometimes weirdly normalized, so we need to compensate + switch (sharee.type) { + case ShareType.User: + return t('files_sharing', 'Shared with {user}', { user: sharee['display-name'] }) + case ShareType.Group: + return t('files_sharing', 'Shared with group {group}', { group: sharee['display-name'] ?? sharee.id }) + default: + return t('files_sharing', 'Shared with others') + } + }, + + iconSvgInline(nodes: Node[]) { + const node = nodes[0] + const shareTypes = Object.values(node?.attributes?.['share-types'] || {}).flat() as number[] + + // Mixed share types + if (Array.isArray(node.attributes?.['share-types']) && node.attributes?.['share-types'].length > 1) { + return AccountPlusSvg + } + + // Link shares + if (shareTypes.includes(ShareType.Link) + || shareTypes.includes(ShareType.Email)) { + return LinkSvg + } + + // Group shares + if (shareTypes.includes(ShareType.Group) + || shareTypes.includes(ShareType.RemoteGroup)) { + return AccountGroupSvg + } + + // Circle shares + if (shareTypes.includes(ShareType.Team)) { + return CircleSvg + } + + if (node.owner && (node.owner !== getCurrentUser()?.uid || isExternal(node))) { + return generateAvatarSvg(node.owner, isExternal(node)) + } + + return AccountPlusSvg + }, + + enabled(nodes: Node[]) { + if (nodes.length !== 1) { + return false + } + + // Do not leak information about users to public shares + if (isPublicShare()) { + return false + } + + const node = nodes[0] + const shareTypes = node.attributes?.['share-types'] + const isMixed = Array.isArray(shareTypes) && shareTypes.length > 0 + + // If the node is shared multiple times with + // different share types to the current user + if (isMixed) { + return true + } + + // If the node is shared by someone else + if (node.owner !== getCurrentUser()?.uid || isExternal(node)) { + return true + } + + return (node.permissions & Permission.SHARE) !== 0 + }, + + async exec(node: Node, view: View, dir: string) { + // You need read permissions to see the sidebar + if ((node.permissions & Permission.READ) !== 0) { + window.OCA?.Files?.Sidebar?.setActiveTab?.('sharing') + return sidebarAction.exec(node, view, dir) + } + return null + }, + + inline: () => true, + +}) + +registerFileAction(action) |