diff options
author | John Molakvoæ <skjnldsv@users.noreply.github.com> | 2023-07-11 15:06:03 +0200 |
---|---|---|
committer | GitHub <noreply@github.com> | 2023-07-11 15:06:03 +0200 |
commit | 2cf8d6d9652a55f81c6800f2e69b71597736c56c (patch) | |
tree | 3e48463d8ebc3c01961243e8e6b1fc10a60133b7 /apps/files_sharing/src | |
parent | 5c6ed30369f5c4edcf46e5e882c6096a7e3cd01e (diff) | |
parent | 74763e875737ea2bb0775194544a809041a2e7d6 (diff) | |
download | nextcloud-server-2cf8d6d9652a55f81c6800f2e69b71597736c56c.tar.gz nextcloud-server-2cf8d6d9652a55f81c6800f2e69b71597736c56c.zip |
Merge pull request #39196 from nextcloud/feat/f2v/sharing
Diffstat (limited to 'apps/files_sharing/src')
15 files changed, 1873 insertions, 5 deletions
diff --git a/apps/files_sharing/src/actions/acceptShareAction.spec.ts b/apps/files_sharing/src/actions/acceptShareAction.spec.ts new file mode 100644 index 00000000000..acef697b1aa --- /dev/null +++ b/apps/files_sharing/src/actions/acceptShareAction.spec.ts @@ -0,0 +1,223 @@ +/** + * @copyright Copyright (c) 2023 John Molakvoæ <skjnldsv@protonmail.com> + * + * @author John Molakvoæ <skjnldsv@protonmail.com> + * + * @license AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + * + */ +import { action } from './acceptShareAction' +import { expect } from '@jest/globals' +import { File, Permission } from '@nextcloud/files' +import { FileAction } from '../../../files/src/services/FileAction' +import * as eventBus from '@nextcloud/event-bus' +import axios from '@nextcloud/axios' +import type { Navigation } from '../../../files/src/services/Navigation' +import '../main' + +const view = { + id: 'files', + name: 'Files', +} as Navigation + +const pendingShareView = { + id: 'pendingshares', + name: 'Pending shares', +} as Navigation + +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)).toBe('<svg>SvgMock</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', () => { + test('Accept share action', async () => { + jest.spyOn(axios, 'post') + jest.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: window.OC.Share.SHARE_TYPE_USER, + }, + }) + + const exec = await action.exec(file, pendingShareView, '/') + + expect(exec).toBe(true) + expect(axios.post).toBeCalledTimes(1) + expect(axios.post).toBeCalledWith('http://localhost/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 () => { + jest.spyOn(axios, 'post') + jest.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: window.OC.Share.SHARE_TYPE_USER, + }, + }) + + const exec = await action.exec(file, pendingShareView, '/') + + expect(exec).toBe(true) + expect(axios.post).toBeCalledTimes(1) + expect(axios.post).toBeCalledWith('http://localhost/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 () => { + jest.spyOn(axios, 'post') + jest.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: window.OC.Share.SHARE_TYPE_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: window.OC.Share.SHARE_TYPE_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://localhost/ocs/v2.php/apps/files_sharing/api/v1/shares/pending/123') + expect(axios.post).toHaveBeenNthCalledWith(2, 'http://localhost/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 () => { + jest.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: window.OC.Share.SHARE_TYPE_USER, + }, + }) + + const exec = await action.exec(file, pendingShareView, '/') + + expect(exec).toBe(false) + expect(axios.post).toBeCalledTimes(1) + expect(axios.post).toBeCalledWith('http://localhost/ocs/v2.php/apps/files_sharing/api/v1/shares/pending/123') + + expect(eventBus.emit).toBeCalledTimes(0) + }) +}) diff --git a/apps/files_sharing/src/actions/acceptShareAction.ts b/apps/files_sharing/src/actions/acceptShareAction.ts new file mode 100644 index 00000000000..4be69633122 --- /dev/null +++ b/apps/files_sharing/src/actions/acceptShareAction.ts @@ -0,0 +1,66 @@ +/** + * @copyright Copyright (c) 2023 John Molakvoæ <skjnldsv@protonmail.com> + * + * @author John Molakvoæ <skjnldsv@protonmail.com> + * + * @license AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + * + */ +import type { Node } from '@nextcloud/files' +import type { Navigation } from '../../../files/src/services/Navigation' + +import { emit } from '@nextcloud/event-bus' +import { generateOcsUrl } from '@nextcloud/router' +import { translatePlural as n } from '@nextcloud/l10n' +import axios from '@nextcloud/axios' +import CheckSvg from '@mdi/svg/svg/check.svg?raw' + +import { FileAction, registerFileAction } from '../../../files/src/services/FileAction' +import { pendingSharesViewId } from '../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: Navigation, 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/actions/openInFilesAction.spec.ts b/apps/files_sharing/src/actions/openInFilesAction.spec.ts new file mode 100644 index 00000000000..097f825bd36 --- /dev/null +++ b/apps/files_sharing/src/actions/openInFilesAction.spec.ts @@ -0,0 +1,97 @@ +/** + * @copyright Copyright (c) 2023 John Molakvoæ <skjnldsv@protonmail.com> + * + * @author John Molakvoæ <skjnldsv@protonmail.com> + * + * @license AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + * + */ +import { action } from './openInFilesAction' +import { expect } from '@jest/globals' +import { File, Permission } from '@nextcloud/files' +import { DefaultType, FileAction } from '../../../files/src/services/FileAction' +import * as eventBus from '@nextcloud/event-bus' +import axios from '@nextcloud/axios' +import type { Navigation } from '../../../files/src/services/Navigation' +import '../main' +import { deletedSharesViewId, pendingSharesViewId, sharedWithOthersViewId, sharedWithYouViewId, sharesViewId, sharingByLinksViewId } from '../views/shares' + +const view = { + id: 'files', + name: 'Files', +} as Navigation + +const validViews = [ + sharesViewId, + sharedWithYouViewId, + sharedWithOthersViewId, + sharingByLinksViewId, +].map(id => ({ id, name: id })) as Navigation[] + +const invalidViews = [ + deletedSharesViewId, + pendingSharesViewId, +].map(id => ({ id, name: id })) as Navigation[] + +describe('Open in files action conditions tests', () => { + test('Default values', () => { + expect(action).toBeInstanceOf(FileAction) + expect(action.id).toBe('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 = jest.fn() + 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' }, { fileid: 1, dir: '/Foo' }) + }) +}) diff --git a/apps/files_sharing/src/actions/openInFilesAction.ts b/apps/files_sharing/src/actions/openInFilesAction.ts new file mode 100644 index 00000000000..4c60b2882b6 --- /dev/null +++ b/apps/files_sharing/src/actions/openInFilesAction.ts @@ -0,0 +1,56 @@ +/** + * @copyright Copyright (c) 2023 John Molakvoæ <skjnldsv@protonmail.com> + * + * @author John Molakvoæ <skjnldsv@protonmail.com> + * + * @license AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + * + */ +import { translate as t } from '@nextcloud/l10n' +import type { Node } from '@nextcloud/files' + +import { registerFileAction, FileAction, DefaultType } from '../../../files/src/services/FileAction' +import { sharesViewId, sharedWithYouViewId, sharedWithOthersViewId, sharingByLinksViewId } from '../views/shares' + +export const action = new FileAction({ + id: 'open-in-files', + displayName: () => t('files', '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) { + window.OCP.Files.Router.goToRoute( + null, // use default route + { view: 'files', fileid: node.fileid }, + { dir: node.dirname, fileid: node.fileid }, + ) + return null + }, + + default: DefaultType.HIDDEN, + // Before openFolderAction + order: -1000, +}) + +registerFileAction(action) diff --git a/apps/files_sharing/src/actions/rejectShareAction.spec.ts b/apps/files_sharing/src/actions/rejectShareAction.spec.ts new file mode 100644 index 00000000000..a075b45eedb --- /dev/null +++ b/apps/files_sharing/src/actions/rejectShareAction.spec.ts @@ -0,0 +1,250 @@ +/** + * @copyright Copyright (c) 2023 John Molakvoæ <skjnldsv@protonmail.com> + * + * @author John Molakvoæ <skjnldsv@protonmail.com> + * + * @license AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + * + */ +import { action } from './rejectShareAction' +import { expect } from '@jest/globals' +import { File, Folder, Permission } from '@nextcloud/files' +import { FileAction } from '../../../files/src/services/FileAction' +import * as eventBus from '@nextcloud/event-bus' +import axios from '@nextcloud/axios' +import type { Navigation } from '../../../files/src/services/Navigation' +import '../main' + +const view = { + id: 'files', + name: 'Files', +} as Navigation + +const pendingShareView = { + id: 'pendingshares', + name: 'Pending shares', +} as Navigation + +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)).toBe('<svg>SvgMock</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: window.OC.Share.SHARE_TYPE_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: window.OC.Share.SHARE_TYPE_REMOTE_GROUP, + }, + }) + + 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', () => { + test('Reject share action', async () => { + jest.spyOn(axios, 'delete') + jest.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: window.OC.Share.SHARE_TYPE_USER, + }, + }) + + const exec = await action.exec(file, pendingShareView, '/') + + expect(exec).toBe(true) + expect(axios.delete).toBeCalledTimes(1) + expect(axios.delete).toBeCalledWith('http://localhost/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 () => { + jest.spyOn(axios, 'delete') + jest.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: window.OC.Share.SHARE_TYPE_USER, + }, + }) + + const exec = await action.exec(file, pendingShareView, '/') + + expect(exec).toBe(true) + expect(axios.delete).toBeCalledTimes(1) + expect(axios.delete).toBeCalledWith('http://localhost/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 () => { + jest.spyOn(axios, 'delete') + jest.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: window.OC.Share.SHARE_TYPE_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: window.OC.Share.SHARE_TYPE_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://localhost/ocs/v2.php/apps/files_sharing/api/v1/shares/123') + expect(axios.delete).toHaveBeenNthCalledWith(2, 'http://localhost/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 () => { + jest.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: window.OC.Share.SHARE_TYPE_USER, + }, + }) + + const exec = await action.exec(file, pendingShareView, '/') + + expect(exec).toBe(false) + expect(axios.delete).toBeCalledTimes(1) + expect(axios.delete).toBeCalledWith('http://localhost/ocs/v2.php/apps/files_sharing/api/v1/shares/123') + + expect(eventBus.emit).toBeCalledTimes(0) + }) +}) diff --git a/apps/files_sharing/src/actions/rejectShareAction.ts b/apps/files_sharing/src/actions/rejectShareAction.ts new file mode 100644 index 00000000000..44dd36abe55 --- /dev/null +++ b/apps/files_sharing/src/actions/rejectShareAction.ts @@ -0,0 +1,83 @@ +/** + * @copyright Copyright (c) 2023 John Molakvoæ <skjnldsv@protonmail.com> + * + * @author John Molakvoæ <skjnldsv@protonmail.com> + * + * @license AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + * + */ +import type { Node } from '@nextcloud/files' +import type { Navigation } from '../../../files/src/services/Navigation' + +import { emit } from '@nextcloud/event-bus' +import { generateOcsUrl } from '@nextcloud/router' +import { translatePlural as n } from '@nextcloud/l10n' +import axios from '@nextcloud/axios' +import CloseSvg from '@mdi/svg/svg/close.svg?raw' + +import { FileAction, registerFileAction } from '../../../files/src/services/FileAction' +import { pendingSharesViewId } from '../views/shares' + +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 === window.OC.Share.SHARE_TYPE_REMOTE_GROUP)) { + 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: Navigation, 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/actions/restoreShareAction.spec.ts b/apps/files_sharing/src/actions/restoreShareAction.spec.ts new file mode 100644 index 00000000000..6b87d0549cf --- /dev/null +++ b/apps/files_sharing/src/actions/restoreShareAction.spec.ts @@ -0,0 +1,196 @@ +/** + * @copyright Copyright (c) 2023 John Molakvoæ <skjnldsv@protonmail.com> + * + * @author John Molakvoæ <skjnldsv@protonmail.com> + * + * @license AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + * + */ +import { action } from './restoreShareAction' +import { expect } from '@jest/globals' +import { File, Permission } from '@nextcloud/files' +import { FileAction } from '../../../files/src/services/FileAction' +import * as eventBus from '@nextcloud/event-bus' +import axios from '@nextcloud/axios' +import type { Navigation } from '../../../files/src/services/Navigation' +import '../main' + +const view = { + id: 'files', + name: 'Files', +} as Navigation + +const deletedShareView = { + id: 'deletedshares', + name: 'Deleted shares', +} as Navigation + +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)).toBe('<svg>SvgMock</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', () => { + test('Restore share action', async () => { + jest.spyOn(axios, 'post') + jest.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: window.OC.Share.SHARE_TYPE_USER, + }, + }) + + const exec = await action.exec(file, deletedShareView, '/') + + expect(exec).toBe(true) + expect(axios.post).toBeCalledTimes(1) + expect(axios.post).toBeCalledWith('http://localhost/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 () => { + jest.spyOn(axios, 'post') + jest.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: window.OC.Share.SHARE_TYPE_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: window.OC.Share.SHARE_TYPE_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://localhost/ocs/v2.php/apps/files_sharing/api/v1/deletedshares/123') + expect(axios.post).toHaveBeenNthCalledWith(2, 'http://localhost/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 () => { + jest.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: window.OC.Share.SHARE_TYPE_USER, + }, + }) + + const exec = await action.exec(file, deletedShareView, '/') + + expect(exec).toBe(false) + expect(axios.post).toBeCalledTimes(1) + expect(axios.post).toBeCalledWith('http://localhost/ocs/v2.php/apps/files_sharing/api/v1/deletedshares/123') + + expect(eventBus.emit).toBeCalledTimes(0) + }) +}) diff --git a/apps/files_sharing/src/actions/restoreShareAction.ts b/apps/files_sharing/src/actions/restoreShareAction.ts new file mode 100644 index 00000000000..6c43b0cfb37 --- /dev/null +++ b/apps/files_sharing/src/actions/restoreShareAction.ts @@ -0,0 +1,65 @@ +/** + * @copyright Copyright (c) 2023 John Molakvoæ <skjnldsv@protonmail.com> + * + * @author John Molakvoæ <skjnldsv@protonmail.com> + * + * @license AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + * + */ +import type { Node } from '@nextcloud/files' +import type { Navigation } from '../../../files/src/services/Navigation' + +import { emit } from '@nextcloud/event-bus' +import { generateOcsUrl } from '@nextcloud/router' +import { translatePlural as n } from '@nextcloud/l10n' +import axios from '@nextcloud/axios' +import ArrowULeftTopSvg from '@mdi/svg/svg/arrow-u-left-top.svg?raw' + +import { FileAction, registerFileAction } from '../../../files/src/services/FileAction' +import { deletedSharesViewId } from '../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: Navigation, 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_sharing.ts b/apps/files_sharing/src/files_sharing.ts new file mode 100644 index 00000000000..939cc91905d --- /dev/null +++ b/apps/files_sharing/src/files_sharing.ts @@ -0,0 +1,30 @@ +/** + * @copyright Copyright (c) 2019 John Molakvoæ <skjnldsv@protonmail.com> + * + * @author John Molakvoæ <skjnldsv@protonmail.com> + * @author Julius Härtl <jus@bitgrid.net> + * + * @license AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + * + */ +import registerSharingViews from './views/shares' + +import './actions/acceptShareAction' +import './actions/openInFilesAction' +import './actions/rejectShareAction' +import './actions/restoreShareAction' + +registerSharingViews() diff --git a/apps/files_sharing/src/index.js b/apps/files_sharing/src/main.ts index 95ed017bbf9..8462d5b542e 100644 --- a/apps/files_sharing/src/index.js +++ b/apps/files_sharing/src/main.ts @@ -22,7 +22,11 @@ */ // register default shares types -Object.assign(OC, { +if (!window.OC) { + window.OC = {} +} + +Object.assign(window.OC, { Share: { SHARE_TYPE_USER: 0, SHARE_TYPE_GROUP: 1, diff --git a/apps/files_sharing/src/services/SharingService.spec.ts b/apps/files_sharing/src/services/SharingService.spec.ts new file mode 100644 index 00000000000..a3269ac7180 --- /dev/null +++ b/apps/files_sharing/src/services/SharingService.spec.ts @@ -0,0 +1,364 @@ +/** + * @copyright Copyright (c) 2023 John Molakvoæ <skjnldsv@protonmail.com> + * + * @author John Molakvoæ <skjnldsv@protonmail.com> + * + * @license AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + * + */ +import { expect } from '@jest/globals' +import axios from '@nextcloud/axios' +import { Type } from '@nextcloud/sharing' +import * as auth from '@nextcloud/auth' + +import { getContents, type OCSResponse } from './SharingService' +import { File, Folder } from '@nextcloud/files' +import logger from './logger' + +global.window.OC = { + TAG_FAVORITE: '_$!<Favorite>!$_', +} + +describe('SharingService methods definitions', () => { + beforeAll(() => { + jest.spyOn(axios, 'get').mockImplementation(async (): Promise<any> => { + return { + data: { + ocs: { + meta: { + status: 'ok', + statuscode: 200, + message: 'OK', + }, + data: [], + }, + } as OCSResponse, + } + }) + }) + + afterAll(() => { + jest.restoreAllMocks() + }) + + test('Shared with you', async () => { + await getContents(true, false, false, false, []) + + expect(axios.get).toHaveBeenCalledTimes(2) + expect(axios.get).toHaveBeenNthCalledWith(1, 'http://localhost/ocs/v2.php/apps/files_sharing/api/v1/shares', { + headers: { + 'Content-Type': 'application/json', + }, + params: { + shared_with_me: true, + include_tags: true, + }, + }) + expect(axios.get).toHaveBeenNthCalledWith(2, 'http://localhost/ocs/v2.php/apps/files_sharing/api/v1/remote_shares', { + headers: { + 'Content-Type': 'application/json', + }, + params: { + include_tags: true, + }, + }) + }) + + test('Shared with others', async () => { + await getContents(false, true, false, false, []) + + expect(axios.get).toHaveBeenCalledTimes(1) + expect(axios.get).toHaveBeenCalledWith('http://localhost/ocs/v2.php/apps/files_sharing/api/v1/shares', { + headers: { + 'Content-Type': 'application/json', + }, + params: { + shared_with_me: false, + include_tags: true, + }, + }) + }) + + test('Pending shares', async () => { + await getContents(false, false, true, false, []) + + expect(axios.get).toHaveBeenCalledTimes(2) + expect(axios.get).toHaveBeenNthCalledWith(1, 'http://localhost/ocs/v2.php/apps/files_sharing/api/v1/shares/pending', { + headers: { + 'Content-Type': 'application/json', + }, + params: { + include_tags: true, + }, + }) + expect(axios.get).toHaveBeenNthCalledWith(2, 'http://localhost/ocs/v2.php/apps/files_sharing/api/v1/remote_shares/pending', { + headers: { + 'Content-Type': 'application/json', + }, + params: { + include_tags: true, + }, + }) + }) + + test('Deleted shares', async () => { + await getContents(false, true, false, false, []) + + expect(axios.get).toHaveBeenCalledTimes(1) + expect(axios.get).toHaveBeenCalledWith('http://localhost/ocs/v2.php/apps/files_sharing/api/v1/shares', { + headers: { + 'Content-Type': 'application/json', + }, + params: { + shared_with_me: false, + include_tags: true, + }, + }) + }) + + test('Unknown owner', async () => { + jest.spyOn(auth, 'getCurrentUser').mockReturnValue(null) + const results = await getContents(false, true, false, false, []) + + expect(results.folder.owner).toEqual(null) + }) +}) + +describe('SharingService filtering', () => { + beforeAll(() => { + jest.spyOn(axios, 'get').mockImplementation(async (): Promise<any> => { + return { + data: { + ocs: { + meta: { + status: 'ok', + statuscode: 200, + message: 'OK', + }, + data: [ + { + id: '62', + share_type: Type.SHARE_TYPE_USER, + uid_owner: 'test', + displayname_owner: 'test', + permissions: 31, + stime: 1688666292, + expiration: '2023-07-13 00:00:00', + token: null, + path: '/Collaborators', + item_type: 'folder', + item_permissions: 31, + mimetype: 'httpd/unix-directory', + storage: 224, + item_source: 419413, + file_source: 419413, + file_parent: 419336, + file_target: '/Collaborators', + item_size: 41434, + item_mtime: 1688662980, + }, + ], + }, + }, + } + }) + }) + + afterAll(() => { + jest.restoreAllMocks() + }) + + test('Shared with others filtering', async () => { + const shares = await getContents(false, true, false, false, [Type.SHARE_TYPE_USER]) + + expect(axios.get).toHaveBeenCalledTimes(1) + expect(shares.contents).toHaveLength(1) + expect(shares.contents[0].fileid).toBe(419413) + expect(shares.contents[0]).toBeInstanceOf(Folder) + }) + + test('Shared with others filtering empty', async () => { + const shares = await getContents(false, true, false, false, [Type.SHARE_TYPE_LINK]) + + expect(axios.get).toHaveBeenCalledTimes(1) + expect(shares.contents).toHaveLength(0) + }) +}) + +describe('SharingService share to Node mapping', () => { + const shareFile = { + id: '66', + share_type: 0, + uid_owner: 'test', + displayname_owner: 'test', + permissions: 19, + can_edit: true, + can_delete: true, + stime: 1688721609, + parent: null, + expiration: '2023-07-14 00:00:00', + token: null, + uid_file_owner: 'test', + note: '', + label: null, + displayname_file_owner: 'test', + path: '/document.md', + item_type: 'file', + item_permissions: 27, + mimetype: 'text/markdown', + has_preview: true, + storage_id: 'home::test', + storage: 224, + item_source: 530936, + file_source: 530936, + file_parent: 419336, + file_target: '/document.md', + item_size: 123, + item_mtime: 1688721600, + share_with: 'user00', + share_with_displayname: 'User00', + share_with_displayname_unique: 'user00@domain.com', + status: { + status: 'away', + message: null, + icon: null, + clearAt: null, + }, + mail_send: 0, + hide_download: 0, + attributes: null, + tags: [], + } + + const shareFolder = { + id: '67', + share_type: 0, + uid_owner: 'test', + displayname_owner: 'test', + permissions: 31, + can_edit: true, + can_delete: true, + stime: 1688721629, + parent: null, + expiration: '2023-07-14 00:00:00', + token: null, + uid_file_owner: 'test', + note: '', + label: null, + displayname_file_owner: 'test', + path: '/Folder', + item_type: 'folder', + item_permissions: 31, + mimetype: 'httpd/unix-directory', + has_preview: false, + storage_id: 'home::test', + storage: 224, + item_source: 531080, + file_source: 531080, + file_parent: 419336, + file_target: '/Folder', + item_size: 0, + item_mtime: 1688721623, + share_with: 'user00', + share_with_displayname: 'User00', + share_with_displayname_unique: 'user00@domain.com', + status: { + status: 'away', + message: null, + icon: null, + clearAt: null, + }, + mail_send: 0, + hide_download: 0, + attributes: null, + tags: [window.OC.TAG_FAVORITE], + } + + test('File', async () => { + jest.spyOn(axios, 'get').mockReturnValueOnce(Promise.resolve({ + data: { + ocs: { + data: [shareFile], + }, + }, + })) + + const shares = await getContents(false, true, false, false) + + expect(axios.get).toHaveBeenCalledTimes(1) + expect(shares.contents).toHaveLength(1) + + const file = shares.contents[0] as File + expect(file).toBeInstanceOf(File) + expect(file.fileid).toBe(530936) + expect(file.source).toBe('http://localhost/remote.php/dav/files/test/document.md') + expect(file.owner).toBe('test') + expect(file.mime).toBe('text/markdown') + expect(file.mtime).toBeInstanceOf(Date) + expect(file.size).toBe(123) + expect(file.permissions).toBe(27) + expect(file.root).toBe('/files/test') + expect(file.attributes).toBeInstanceOf(Object) + expect(file.attributes['has-preview']).toBe(true) + expect(file.attributes.previewUrl).toBe('/index.php/core/preview?fileId=530936&x=32&y=32&forceIcon=0') + expect(file.attributes.favorite).toBe(0) + }) + + test('Folder', async () => { + jest.spyOn(axios, 'get').mockReturnValueOnce(Promise.resolve({ + data: { + ocs: { + data: [shareFolder], + }, + }, + })) + + const shares = await getContents(false, true, false, false) + + expect(axios.get).toHaveBeenCalledTimes(1) + expect(shares.contents).toHaveLength(1) + + const folder = shares.contents[0] as Folder + expect(folder).toBeInstanceOf(Folder) + expect(folder.fileid).toBe(531080) + expect(folder.source).toBe('http://localhost/remote.php/dav/files/test/Folder') + expect(folder.owner).toBe('test') + expect(folder.mime).toBe('httpd/unix-directory') + expect(folder.mtime).toBeInstanceOf(Date) + expect(folder.size).toBe(0) + expect(folder.permissions).toBe(31) + expect(folder.root).toBe('/files/test') + expect(folder.attributes).toBeInstanceOf(Object) + expect(folder.attributes['has-preview']).toBe(false) + expect(folder.attributes.previewUrl).toBeUndefined() + expect(folder.attributes.favorite).toBe(1) + }) + + test('Error', async () => { + jest.spyOn(logger, 'error').mockImplementationOnce(() => {}) + jest.spyOn(axios, 'get').mockReturnValueOnce(Promise.resolve({ + data: { + ocs: { + data: [{}], + }, + }, + })) + + const shares = await getContents(false, true, false, false) + expect(shares.contents).toHaveLength(0) + expect(logger.error).toHaveBeenCalledTimes(1) + }) +}) diff --git a/apps/files_sharing/src/services/SharingService.ts b/apps/files_sharing/src/services/SharingService.ts new file mode 100644 index 00000000000..8d11c223b5d --- /dev/null +++ b/apps/files_sharing/src/services/SharingService.ts @@ -0,0 +1,181 @@ +/** + * @copyright Copyright (c) 2023 John Molakvoæ <skjnldsv@protonmail.com> + * + * @author John Molakvoæ <skjnldsv@protonmail.com> + * + * @license AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + * + */ +/* eslint-disable camelcase, n/no-extraneous-import */ +import type { AxiosPromise } from 'axios' +import type { ContentsWithRoot } from '../../../files/src/services/Navigation' + +import { Folder, File } from '@nextcloud/files' +import { generateOcsUrl, generateRemoteUrl, generateUrl } from '@nextcloud/router' +import { getCurrentUser } from '@nextcloud/auth' +import axios from '@nextcloud/axios' +import logger from './logger' + +export const rootPath = `/files/${getCurrentUser()?.uid}` + +export type OCSResponse = { + ocs: { + meta: { + status: string + statuscode: number + message: string + }, + data: [] + } +} + +const headers = { + 'Content-Type': 'application/json', +} + +const ocsEntryToNode = function(ocsEntry: any): Folder | File | null { + try { + const isFolder = ocsEntry?.item_type === 'folder' + const hasPreview = ocsEntry?.has_preview === true + const Node = isFolder ? Folder : File + + const fileid = ocsEntry.file_source + const previewUrl = hasPreview ? generateUrl('/core/preview?fileId={fileid}&x=32&y=32&forceIcon=0', { fileid }) : undefined + + // Generate path and strip double slashes + const path = ocsEntry?.path || ocsEntry.file_target + const source = generateRemoteUrl(`dav/${rootPath}/${path}`.replaceAll(/\/\//gm, '/')) + + // Prefer share time if more recent than item mtime + let mtime = ocsEntry?.item_mtime ? new Date((ocsEntry.item_mtime) * 1000) : undefined + if (ocsEntry?.stime > (ocsEntry?.item_mtime || 0)) { + mtime = new Date((ocsEntry.stime) * 1000) + } + + return new Node({ + id: fileid, + source, + owner: ocsEntry?.uid_owner, + mime: ocsEntry?.mimetype, + mtime, + size: ocsEntry?.item_size, + permissions: ocsEntry?.item_permissions || ocsEntry?.permissions, + root: rootPath, + attributes: { + ...ocsEntry, + previewUrl, + 'has-preview': hasPreview, + favorite: ocsEntry?.tags?.includes(window.OC.TAG_FAVORITE) ? 1 : 0, + }, + }) + } catch (error) { + logger.error('Error while parsing OCS entry', { error }) + return null + } +} + +const getShares = function(shared_with_me = false): AxiosPromise<OCSResponse> { + const url = generateOcsUrl('apps/files_sharing/api/v1/shares') + return axios.get(url, { + headers, + params: { + shared_with_me, + include_tags: true, + }, + }) +} + +const getSharedWithYou = function(): AxiosPromise<OCSResponse> { + return getShares(true) +} + +const getSharedWithOthers = function(): AxiosPromise<OCSResponse> { + return getShares() +} + +const getRemoteShares = function(): AxiosPromise<OCSResponse> { + const url = generateOcsUrl('apps/files_sharing/api/v1/remote_shares') + return axios.get(url, { + headers, + params: { + include_tags: true, + }, + }) +} + +const getPendingShares = function(): AxiosPromise<OCSResponse> { + const url = generateOcsUrl('apps/files_sharing/api/v1/shares/pending') + return axios.get(url, { + headers, + params: { + include_tags: true, + }, + }) +} + +const getRemotePendingShares = function(): AxiosPromise<OCSResponse> { + const url = generateOcsUrl('apps/files_sharing/api/v1/remote_shares/pending') + return axios.get(url, { + headers, + params: { + include_tags: true, + }, + }) +} + +const getDeletedShares = function(): AxiosPromise<OCSResponse> { + const url = generateOcsUrl('apps/files_sharing/api/v1/deletedshares') + return axios.get(url, { + headers, + params: { + include_tags: true, + }, + }) +} + +export const getContents = async (sharedWithYou = true, sharedWithOthers = true, pendingShares = false, deletedshares = false, filterTypes: number[] = []): Promise<ContentsWithRoot> => { + const promises = [] as AxiosPromise<OCSResponse>[] + + if (sharedWithYou) { + promises.push(getSharedWithYou(), getRemoteShares()) + } + if (sharedWithOthers) { + promises.push(getSharedWithOthers()) + } + if (pendingShares) { + promises.push(getPendingShares(), getRemotePendingShares()) + } + if (deletedshares) { + promises.push(getDeletedShares()) + } + + const responses = await Promise.all(promises) + const data = responses.map((response) => response.data.ocs.data).flat() + let contents = data.map(ocsEntryToNode).filter((node) => node !== null) as (Folder | File)[] + + if (filterTypes.length > 0) { + contents = contents.filter((node) => filterTypes.includes(node.attributes?.share_type)) + } + + return { + folder: new Folder({ + id: 0, + source: generateRemoteUrl('dav' + rootPath), + owner: getCurrentUser()?.uid || null, + }), + contents, + } +} diff --git a/apps/files_sharing/src/files_sharing.js b/apps/files_sharing/src/services/logger.ts index 0578da7f9c5..19be888bf1f 100644 --- a/apps/files_sharing/src/files_sharing.js +++ b/apps/files_sharing/src/services/logger.ts @@ -1,8 +1,7 @@ /** - * @copyright Copyright (c) 2016 John Molakvoæ <skjnldsv@protonmail.com> + * @copyright Copyright (c) 2022 John Molakvoæ <skjnldsv@protonmail.com> * * @author John Molakvoæ <skjnldsv@protonmail.com> - * @author Julius Härtl <jus@bitgrid.net> * * @license AGPL-3.0-or-later * @@ -20,6 +19,9 @@ * along with this program. If not, see <http://www.gnu.org/licenses/>. * */ +import { getLoggerBuilder } from '@nextcloud/logger' -import '../js/app.js' -import '../js/sharedfilelist.js' +export default getLoggerBuilder() + .setApp('files_sharing') + .detectUser() + .build() diff --git a/apps/files_sharing/src/views/shares.spec.ts b/apps/files_sharing/src/views/shares.spec.ts new file mode 100644 index 00000000000..353e82b6f84 --- /dev/null +++ b/apps/files_sharing/src/views/shares.spec.ts @@ -0,0 +1,125 @@ +/** + * @copyright Copyright (c) 2023 John Molakvoæ <skjnldsv@protonmail.com> + * + * @author John Molakvoæ <skjnldsv@protonmail.com> + * + * @license AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + * + */ +/* eslint-disable n/no-extraneous-import */ +import { expect } from '@jest/globals' +import axios from '@nextcloud/axios' + +import { type Navigation } from '../../../files/src/services/Navigation' +import { type OCSResponse } from '../services/SharingService' +import NavigationService from '../../../files/src/services/Navigation' +import registerSharingViews from './shares' + +import '../main' +import { Folder } from '@nextcloud/files' + +describe('Sharing views definition', () => { + let Navigation + beforeEach(() => { + Navigation = new NavigationService() + window.OCP = { Files: { Navigation } } + }) + + afterAll(() => { + delete window.OCP + }) + + test('Default values', () => { + jest.spyOn(Navigation, 'register') + + expect(Navigation.views.length).toBe(0) + + registerSharingViews() + const shareOverviewView = Navigation.views.find(view => view.id === 'shareoverview') as Navigation + const sharesChildViews = Navigation.views.filter(view => view.parent === 'shareoverview') as Navigation[] + + expect(Navigation.register).toHaveBeenCalledTimes(6) + + // one main view and no children + expect(Navigation.views.length).toBe(6) + expect(shareOverviewView).toBeDefined() + expect(sharesChildViews.length).toBe(5) + + expect(shareOverviewView?.id).toBe('shareoverview') + expect(shareOverviewView?.name).toBe('Shares') + expect(shareOverviewView?.caption).toBe('Overview of shared files.') + expect(shareOverviewView?.icon).toBe('<svg>SvgMock</svg>') + expect(shareOverviewView?.order).toBe(20) + expect(shareOverviewView?.columns).toStrictEqual([]) + expect(shareOverviewView?.getContents).toBeDefined() + + const dataProvider = [ + { id: 'sharingin', name: 'Shared with you', caption: 'List of files that are shared with you.' }, + { id: 'sharingout', name: 'Shared with others', caption: 'List of files that you shared with others.' }, + { id: 'sharinglinks', name: 'Shared by link', caption: 'List of files that are shared by link.' }, + { id: 'deletedshares', name: 'Deleted shares', caption: 'List of shares that you removed yourself from.' }, + { id: 'pendingshares', name: 'Pending shares', caption: 'List of unapproved shares.' }, + ] + + sharesChildViews.forEach((view, index) => { + expect(view?.id).toBe(dataProvider[index].id) + expect(view?.parent).toBe('shareoverview') + expect(view?.name).toBe(dataProvider[index].name) + expect(view?.caption).toBe(dataProvider[index].caption) + expect(view?.icon).toBe('<svg>SvgMock</svg>') + expect(view?.order).toBe(index + 1) + expect(view?.columns).toStrictEqual([]) + expect(view?.getContents).toBeDefined() + }) + }) +}) + +describe('Sharing views contents', () => { + let Navigation + beforeEach(() => { + Navigation = new NavigationService() + window.OCP = { Files: { Navigation } } + }) + + afterAll(() => { + delete window.OCP + }) + + test('Sharing overview get contents', async () => { + jest.spyOn(axios, 'get').mockImplementation(async (): Promise<any> => { + return { + data: { + ocs: { + meta: { + status: 'ok', + statuscode: 200, + message: 'OK', + }, + data: [], + }, + } as OCSResponse, + } + }) + + registerSharingViews() + expect(Navigation.views.length).toBe(6) + Navigation.views.forEach(async (view: Navigation) => { + const content = await view.getContents('/') + expect(content.contents).toStrictEqual([]) + expect(content.folder).toBeInstanceOf(Folder) + }) + }) +}) diff --git a/apps/files_sharing/src/views/shares.ts b/apps/files_sharing/src/views/shares.ts new file mode 100644 index 00000000000..97d92adeb69 --- /dev/null +++ b/apps/files_sharing/src/views/shares.ts @@ -0,0 +1,126 @@ +/** + * @copyright Copyright (c) 2023 John Molakvoæ <skjnldsv@protonmail.com> + * + * @author John Molakvoæ <skjnldsv@protonmail.com> + * + * @license AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + * + */ +import type NavigationService from '../../../files/src/services/Navigation' +import type { Navigation } from '../../../files/src/services/Navigation' + +import { translate as t } from '@nextcloud/l10n' +import AccountClockSvg from '@mdi/svg/svg/account-clock.svg?raw' +import AccountGroupSvg from '@mdi/svg/svg/account-group.svg?raw' +import AccountSvg from '@mdi/svg/svg/account.svg?raw' +import DeleteSvg from '@mdi/svg/svg/delete.svg?raw' +import LinkSvg from '@mdi/svg/svg/link.svg?raw' +import ShareVariantSvg from '@mdi/svg/svg/share-variant.svg?raw' + +import { getContents } from '../services/SharingService' + +export const sharesViewId = 'shareoverview' +export const sharedWithYouViewId = 'sharingin' +export const sharedWithOthersViewId = 'sharingout' +export const sharingByLinksViewId = 'sharinglinks' +export const deletedSharesViewId = 'deletedshares' +export const pendingSharesViewId = 'pendingshares' + +export default () => { + const Navigation = window.OCP.Files.Navigation as NavigationService + Navigation.register({ + id: sharesViewId, + name: t('files_sharing', 'Shares'), + caption: t('files_sharing', 'Overview of shared files.'), + + icon: ShareVariantSvg, + order: 20, + + columns: [], + + getContents: () => getContents(), + } as Navigation) + + Navigation.register({ + id: sharedWithYouViewId, + name: t('files_sharing', 'Shared with you'), + caption: t('files_sharing', 'List of files that are shared with you.'), + + icon: AccountSvg, + order: 1, + parent: sharesViewId, + + columns: [], + + getContents: () => getContents(true, false, false, false), + } as Navigation) + + Navigation.register({ + id: sharedWithOthersViewId, + name: t('files_sharing', 'Shared with others'), + caption: t('files_sharing', 'List of files that you shared with others.'), + + icon: AccountGroupSvg, + order: 2, + parent: sharesViewId, + + columns: [], + + getContents: () => getContents(false, true, false, false), + } as Navigation) + + Navigation.register({ + id: sharingByLinksViewId, + name: t('files_sharing', 'Shared by link'), + caption: t('files_sharing', 'List of files that are shared by link.'), + + icon: LinkSvg, + order: 3, + parent: sharesViewId, + + columns: [], + + getContents: () => getContents(false, true, false, false, [window.OC.Share.SHARE_TYPE_LINK]), + } as Navigation) + + Navigation.register({ + id: deletedSharesViewId, + name: t('files_sharing', 'Deleted shares'), + caption: t('files_sharing', 'List of shares that you removed yourself from.'), + + icon: DeleteSvg, + order: 4, + parent: sharesViewId, + + columns: [], + + getContents: () => getContents(false, false, false, true), + } as Navigation) + + Navigation.register({ + id: pendingSharesViewId, + name: t('files_sharing', 'Pending shares'), + caption: t('files_sharing', 'List of unapproved shares.'), + + icon: AccountClockSvg, + order: 5, + parent: sharesViewId, + + columns: [], + + getContents: () => getContents(false, false, true, false), + } as Navigation) +} |