aboutsummaryrefslogtreecommitdiffstats
path: root/apps/files_sharing/src
diff options
context:
space:
mode:
authorJohn Molakvoæ <skjnldsv@users.noreply.github.com>2023-07-11 15:06:03 +0200
committerGitHub <noreply@github.com>2023-07-11 15:06:03 +0200
commit2cf8d6d9652a55f81c6800f2e69b71597736c56c (patch)
tree3e48463d8ebc3c01961243e8e6b1fc10a60133b7 /apps/files_sharing/src
parent5c6ed30369f5c4edcf46e5e882c6096a7e3cd01e (diff)
parent74763e875737ea2bb0775194544a809041a2e7d6 (diff)
downloadnextcloud-server-2cf8d6d9652a55f81c6800f2e69b71597736c56c.tar.gz
nextcloud-server-2cf8d6d9652a55f81c6800f2e69b71597736c56c.zip
Merge pull request #39196 from nextcloud/feat/f2v/sharing
Diffstat (limited to 'apps/files_sharing/src')
-rw-r--r--apps/files_sharing/src/actions/acceptShareAction.spec.ts223
-rw-r--r--apps/files_sharing/src/actions/acceptShareAction.ts66
-rw-r--r--apps/files_sharing/src/actions/openInFilesAction.spec.ts97
-rw-r--r--apps/files_sharing/src/actions/openInFilesAction.ts56
-rw-r--r--apps/files_sharing/src/actions/rejectShareAction.spec.ts250
-rw-r--r--apps/files_sharing/src/actions/rejectShareAction.ts83
-rw-r--r--apps/files_sharing/src/actions/restoreShareAction.spec.ts196
-rw-r--r--apps/files_sharing/src/actions/restoreShareAction.ts65
-rw-r--r--apps/files_sharing/src/files_sharing.ts30
-rw-r--r--apps/files_sharing/src/main.ts (renamed from apps/files_sharing/src/index.js)6
-rw-r--r--apps/files_sharing/src/services/SharingService.spec.ts364
-rw-r--r--apps/files_sharing/src/services/SharingService.ts181
-rw-r--r--apps/files_sharing/src/services/logger.ts (renamed from apps/files_sharing/src/files_sharing.js)10
-rw-r--r--apps/files_sharing/src/views/shares.spec.ts125
-rw-r--r--apps/files_sharing/src/views/shares.ts126
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)
+}