diff --git a/changelog/unreleased/enhancement-create-shortcuts b/changelog/unreleased/enhancement-create-shortcuts new file mode 100644 index 00000000000..5a844d10aab --- /dev/null +++ b/changelog/unreleased/enhancement-create-shortcuts @@ -0,0 +1,12 @@ +Enhancement: Create shortcuts + +We've added a new functionality to add to shortcuts to web, those can be created via the "+ New" context menu. +Users can enter URLs or pick a file via the drop down menu and create a '.url' file. + +'.url' files can be opened via web or downloaded and opened on the desktop. + +https://github.com/owncloud/web/pull/9890 +https://github.com/owncloud/web/pull/9908 +https://github.com/owncloud/web/issues/9796 +https://github.com/owncloud/web/issues/9887 +https://github.com/owncloud/web/issues/9850 diff --git a/packages/web-app-files/package.json b/packages/web-app-files/package.json index 6b243383011..7b63fdfbfd9 100644 --- a/packages/web-app-files/package.json +++ b/packages/web-app-files/package.json @@ -35,6 +35,7 @@ "vuex": "4.1.0", "web-app-files": "workspace:*", "web-app-search": "workspace:*", - "web-runtime": "workspace:*" + "web-runtime": "workspace:*", + "dompurify": "^3.0.6" } } diff --git a/packages/web-app-files/src/components/AppBar/CreateAndUpload.vue b/packages/web-app-files/src/components/AppBar/CreateAndUpload.vue index f1eb15bb667..dc8d794fc25 100644 --- a/packages/web-app-files/src/components/AppBar/CreateAndUpload.vue +++ b/packages/web-app-files/src/components/AppBar/CreateAndUpload.vue @@ -170,9 +170,12 @@ + diff --git a/packages/web-pkg/src/components/FilesList/ContextActions.vue b/packages/web-pkg/src/components/FilesList/ContextActions.vue index e26a2ab0aca..815f11d2f36 100644 --- a/packages/web-pkg/src/components/FilesList/ContextActions.vue +++ b/packages/web-pkg/src/components/FilesList/ContextActions.vue @@ -95,6 +95,7 @@ export default defineComponent({ const menuItemsContext = computed(() => { const fileHandlers = [ + ...unref(openShortcutActions), ...unref(editorActions), ...loadExternalAppActions(unref(actionOptions)) ] @@ -112,7 +113,6 @@ export default defineComponent({ const menuItemsActions = computed(() => { return [ - ...unref(openShortcutActions), ...unref(downloadArchiveActions), ...unref(downloadFileActions), ...unref(deleteActions), diff --git a/packages/web-pkg/src/components/Search/ResourcePreview.vue b/packages/web-pkg/src/components/Search/ResourcePreview.vue index 733c31c8f41..5e19dfeac29 100644 --- a/packages/web-pkg/src/components/Search/ResourcePreview.vue +++ b/packages/web-pkg/src/components/Search/ResourcePreview.vue @@ -3,14 +3,11 @@ :resource="resource" :path-prefix="pathPrefix" :is-path-displayed="true" - :is-resource-clickable="true" :folder-link="folderLink" - :parent-folder-link="parentFolderLink" :parent-folder-link-icon-additional-attributes="parentFolderLinkIconAdditionalAttributes" :parent-folder-name="parentFolderName" :is-thumbnail-displayed="displayThumbnails" - @click="resourceClicked" - @click-folder="folderClicked" + v-bind="additionalAttrs" @click-parent-folder="folderClicked" /> @@ -21,12 +18,7 @@ import { VisibilityObserver } from '../../observer' import { debounce } from 'lodash-es' import { computed, defineComponent, PropType, ref, unref } from 'vue' import { mapGetters } from 'vuex' -import { - useCapabilityShareJailEnabled, - useGetMatchingSpace, - useFileActions, - useFolderLink -} from '../../composables' +import { useGetMatchingSpace, useFileActions, useFolderLink } from '../../composables' import { Resource } from '@ownclouders/web-client/src/helpers' import { eventBus } from '../../services' import { isResourceTxtFileAlmostEmpty } from '../../helpers' @@ -42,11 +34,9 @@ export default defineComponent({ return {} } }, - provider: { - type: Object, - default: function () { - return {} - } + isClickable: { + type: Boolean, + default: true } }, setup(props) { @@ -89,9 +79,22 @@ export default defineComponent({ eventBus.publish('app.search.options-drop.hide') } + const additionalAttrs = computed(() => { + if (!props.isClickable) { + return {} + } + + return { + isResourceClickable: true, + parentFolderLink: getParentFolderLink(unref(resource)), + onClick: resourceClicked, + onClickFolder: folderClicked, + onClickParentFolder: folderClicked + } + }) + return { space, - hasShareJail: useCapabilityShareJailEnabled(), previewData, resource, resourceDisabled, @@ -103,7 +106,8 @@ export default defineComponent({ parentFolderName: getParentFolderName(unref(resource)), parentFolderLinkIconAdditionalAttributes: getParentFolderLinkIconAdditionalAttributes( unref(resource) - ) + ), + additionalAttrs } }, computed: { diff --git a/packages/web-pkg/src/components/Search/types.ts b/packages/web-pkg/src/components/Search/types.ts index 7b69aab40c6..f4147403520 100644 --- a/packages/web-pkg/src/components/Search/types.ts +++ b/packages/web-pkg/src/components/Search/types.ts @@ -1,6 +1,8 @@ +import { SearchResource } from '@ownclouders/web-client/src/webdav/search' + export interface SearchResultValue { id: string | number - data: unknown + data: SearchResource } export interface SearchResult { diff --git a/packages/web-pkg/src/composables/actions/files/useFileActionsOpenShortcut.ts b/packages/web-pkg/src/composables/actions/files/useFileActionsOpenShortcut.ts index 34305f06733..c8605e1082f 100644 --- a/packages/web-pkg/src/composables/actions/files/useFileActionsOpenShortcut.ts +++ b/packages/web-pkg/src/composables/actions/files/useFileActionsOpenShortcut.ts @@ -33,13 +33,22 @@ export const useFileActionsOpenShortcut = ({ store }: { store?: Store } = { } const handler = async ({ resources, space }: FileActionOptions) => { try { + const webURL = new URL(window.location.href) const fileContents = (await clientService.webdav.getFileContents(space, resources[0])).body - const url = extractUrl(fileContents) + let url = extractUrl(fileContents) + + // Add protocol if missing + url = url.match(/^http[s]?:\/\//) ? url : `https://${url}` // Omit possible xss code - const sanitizedUrl = DOMPurify.sanitize(url, { USE_PROFILES: { html: true } }) + url = DOMPurify.sanitize(url, { USE_PROFILES: { html: true } }) + + if (url.startsWith(webURL.origin)) { + window.location.href = url + return + } - window.open(sanitizedUrl) + window.open(url) } catch (e) { console.error(e) store.dispatch('showErrorMessage', { diff --git a/packages/web-pkg/src/composables/search/index.ts b/packages/web-pkg/src/composables/search/index.ts index f87cf0102a1..d9a37121ad6 100644 --- a/packages/web-pkg/src/composables/search/index.ts +++ b/packages/web-pkg/src/composables/search/index.ts @@ -1 +1,2 @@ export * from './constants' +export * from './useSearch' diff --git a/packages/web-pkg/src/composables/search/useSearch.ts b/packages/web-pkg/src/composables/search/useSearch.ts new file mode 100644 index 00000000000..d921c2c2810 --- /dev/null +++ b/packages/web-pkg/src/composables/search/useSearch.ts @@ -0,0 +1,66 @@ +import { computed, unref } from 'vue' +import { SearchResult } from '../../components' +import { DavProperties } from '@ownclouders/web-client/src/webdav' +import { urlJoin } from '@ownclouders/web-client/src/utils' +import { useConfigurationManager } from '../configuration' +import { useStore } from '../store' +import { useClientService } from '../clientService' +import { isProjectSpaceResource } from '@ownclouders/web-client/src/helpers' + +export const useSearch = () => { + const store = useStore() + const configurationManager = useConfigurationManager() + const clientService = useClientService() + + const areHiddenFilesShown = computed(() => store.state.Files?.areHiddenFilesShown) + const projectSpaces = computed(() => + store.getters['runtime/spaces/spaces'].filter((s) => isProjectSpaceResource(s)) + ) + const getProjectSpace = (id) => { + return unref(projectSpaces).find((s) => s.id === id) + } + const search = async (term: string, searchLimit = null): Promise => { + if (configurationManager.options.routing.fullShareOwnerPaths) { + await store.dispatch('runtime/spaces/loadMountPoints', { + graphClient: clientService.graphAuthenticated + }) + } + + if (!term) { + return { + totalResults: null, + values: [] + } + } + + const { resources, totalResults } = await clientService.webdav.search(term, { + searchLimit, + davProperties: DavProperties.Default + }) + + return { + totalResults, + values: resources + .map((resource) => { + const matchingSpace = getProjectSpace(resource.parentFolderId) + const data = matchingSpace ? matchingSpace : resource + + if (configurationManager.options.routing.fullShareOwnerPaths && data.shareRoot) { + data.path = urlJoin(data.shareRoot, data.path) + } + + return { id: data.id, data } + }) + .filter(({ data }) => { + // filter results if hidden files shouldn't be shown due to settings + return !data.name.startsWith('.') || unref(areHiddenFilesShown) + }) + } + } + + return { + search + } +} + +export type SearchFunction = ReturnType['search'] diff --git a/packages/web-pkg/tests/unit/components/CreateShortcutModal.spec.ts b/packages/web-pkg/tests/unit/components/CreateShortcutModal.spec.ts new file mode 100644 index 00000000000..11688a4ff3c --- /dev/null +++ b/packages/web-pkg/tests/unit/components/CreateShortcutModal.spec.ts @@ -0,0 +1,109 @@ +import CreateShortcutModal from '../../../src/components/CreateShortcutModal.vue' +import { + createStore, + defaultComponentMocks, + defaultPlugins, + defaultStoreMockOptions, + mockAxiosReject, + RouteLocation, + shallowMount +} from 'web-test-helpers' +import { SpaceResource } from '@ownclouders/web-client' +import { mock, mockDeep } from 'jest-mock-extended' +import { FileResource } from '@ownclouders/web-client/src/helpers' +import { SearchResource } from '@ownclouders/web-client/src/webdav/search' +import { ConfigurationManager } from '../../../src' + +jest.mock('../../../src/composables/configuration/useConfigurationManager', () => ({ + useConfigurationManager: () => + mockDeep({ + options: { + routing: { + fullShareOwnerPaths: false + } + } + }) +})) + +describe('CreateShortcutModal', () => { + describe('method "createShortcut"', () => { + it('should show message on success', async () => { + const { wrapper, storeOptions } = getWrapper() + await wrapper.vm.createShortcut('https://owncloud.com', 'owncloud.url') + expect(storeOptions.modules.Files.mutations.UPSERT_RESOURCE).toHaveBeenCalled() + expect(storeOptions.actions.showMessage).toHaveBeenCalled() + }) + it('should show error message on fail', async () => { + console.error = jest.fn() + const { wrapper, storeOptions } = getWrapper({ rejectPutFileContents: true }) + await wrapper.vm.createShortcut('https://owncloud.com', 'owncloud.url') + expect(storeOptions.modules.Files.mutations.UPSERT_RESOURCE).not.toHaveBeenCalled() + expect(storeOptions.actions.showErrorMessage).toHaveBeenCalled() + }) + }) + describe('method "searchTask"', () => { + it('should set "searchResult" correctly', async () => { + const { wrapper } = getWrapper() + await wrapper.vm.searchTask.perform('new file') + expect(wrapper.vm.searchResult.values.length).toBe(3) + }) + it('should reset "searchResult" on error', async () => { + console.error = jest.fn() + const { wrapper } = getWrapper({ rejectSearch: true }) + await wrapper.vm.searchTask.perform('new folder') + expect(wrapper.vm.searchResult).toBe(null) + }) + }) +}) + +function getWrapper({ rejectPutFileContents = false, rejectSearch = false } = {}) { + const storeOptions = { + ...defaultStoreMockOptions + } + + storeOptions.modules.Files.getters.currentFolder.mockImplementation(() => mock()) + + const store = createStore(storeOptions) + + const mocks = { + ...defaultComponentMocks({ + currentRoute: mock({ name: 'files-spaces-generic' }) + }) + } + + if (rejectPutFileContents) { + mocks.$clientService.webdav.putFileContents.mockRejectedValue(() => mockAxiosReject()) + } else { + mocks.$clientService.webdav.putFileContents.mockResolvedValue(mock()) + } + + if (rejectSearch) { + mocks.$clientService.webdav.search.mockRejectedValue(() => mockAxiosReject()) + } else { + mocks.$clientService.webdav.search.mockResolvedValue({ + resources: [ + mock({ name: 'New File' }), + mock({ name: 'New File (1)' }), + mock({ name: 'New Folder' }) + ], + totalResults: 3 + }) + } + + return { + mocks, + storeOptions, + wrapper: shallowMount(CreateShortcutModal, { + props: { + cancel: jest.fn(), + space: mock(), + title: 'Personal quota' + }, + global: { + plugins: [...defaultPlugins(), store], + mocks, + provide: mocks + } + }) + } +} diff --git a/packages/web-pkg/tests/unit/composables/actions/files/useFileActionsOpenShortcut.spec.ts b/packages/web-pkg/tests/unit/composables/actions/files/useFileActionsOpenShortcut.spec.ts index 1a8e02c8549..24cd11ee8ab 100644 --- a/packages/web-pkg/tests/unit/composables/actions/files/useFileActionsOpenShortcut.spec.ts +++ b/packages/web-pkg/tests/unit/composables/actions/files/useFileActionsOpenShortcut.spec.ts @@ -7,7 +7,7 @@ import { RouteLocation, getComposableWrapper } from 'web-test-helpers' -import { useFileActionsOpenShortcut, useRoute } from '../../../../../src' +import { ConfigurationManager, useFileActionsOpenShortcut, useRoute } from '../../../../../src' import { Resource } from '@ownclouders/web-client' import { GetFileContentsResponse } from '@ownclouders/web-client/src/webdav/getFileContents' @@ -16,10 +16,17 @@ jest.mock('../../../../../src/composables/router', () => ({ useRoute: jest.fn() })) +jest.mock('../../../../../src/composables/configuration', () => ({ + useConfigurationManager: () => + mock({ + serverUrl: 'https://demo.owncloud.com' + }) +})) + window = Object.create(window) Object.defineProperty(window, 'location', { value: { - href: '' + href: 'https://demo.owncloud.com' }, writable: true }) @@ -55,6 +62,18 @@ describe('openShortcut', () => { }) }) describe('method "handler"', () => { + it('adds http(s) protocol if missing and opens the url in a new tab', () => { + getWrapper({ + getFileContentsValue: '[InternetShortcut]\nURL=owncloud.com', + setup: async ({ actions }) => { + await unref(actions)[0].handler({ + resources: [mock()], + space: null + }) + expect(window.open).toHaveBeenCalledWith('https://owncloud.com') + } + }) + }) it('omits xss code and opens the url in a new tab', () => { getWrapper({ getFileContentsValue: @@ -68,6 +87,18 @@ describe('openShortcut', () => { } }) }) + it('opens the url in the same window if url links to OCIS instance', () => { + getWrapper({ + getFileContentsValue: '[InternetShortcut]\nURL=https://demo.owncloud.com', + setup: async ({ actions }) => { + await unref(actions)[0].handler({ + resources: [mock()], + space: null + }) + expect(window.location.href).toBe('https://demo.owncloud.com') + } + }) + }) }) }) describe('method "extractUrl"', () => { @@ -108,7 +139,6 @@ function getWrapper({ }) } - // url contains xss code to test xss protection mocks.$clientService.webdav.getFileContents.mockResolvedValue( mock({ body: getFileContentsValue diff --git a/packages/web-pkg/tests/unit/composables/search/useSearch.spec.ts b/packages/web-pkg/tests/unit/composables/search/useSearch.spec.ts new file mode 100644 index 00000000000..77fdf2e9604 --- /dev/null +++ b/packages/web-pkg/tests/unit/composables/search/useSearch.spec.ts @@ -0,0 +1,92 @@ +import { mock } from 'jest-mock-extended' +import { + createStore, + defaultComponentMocks, + defaultStoreMockOptions, + getComposableWrapper +} from 'web-test-helpers' +import { useSearch } from '../../../../src/composables' +import { ConfigurationManager } from '../../../../src/configuration' + +jest.mock('../../../../src/composables/configuration', () => ({ + useConfigurationManager: () => + mock({ + options: { + routing: { + fullShareOwnerPaths: false, + idBased: true + } + } + }) +})) + +describe('useSearch', () => { + describe('method "search"', () => { + it('can search', async () => { + const files = [ + { id: 'foo', name: 'foo', fileInfo: {} }, + { id: 'bar', name: 'bar', fileInfo: {} }, + { id: 'baz', name: 'baz', fileInfo: {} } + ] + + const wrapper = createWrapper({ resources: files }) + + const noTermResult = await wrapper.vm.search('') + expect(noTermResult).toEqual({ totalResults: null, values: [] }) + + const withTermResult = await wrapper.vm.search('foo') + expect(withTermResult.values.map((r) => r.data)).toMatchObject(files) + }) + it('properly returns space resources', async () => { + const files = [{ id: 'foo', name: 'foo', parentFolderId: '2' }] + + const wrapper = createWrapper({ resources: files }) + + const withTerm = await wrapper.vm.search('foo') + expect(withTerm.values.map((r) => r.data)[0].id).toEqual('2') + }) + }) +}) + +const createWrapper = ({ resources = [] }: { resources?: any[] } = {}) => { + const storeOptions = { ...defaultStoreMockOptions } + storeOptions.getters.capabilities.mockImplementation(() => ({ + spaces: { projects: true, share_jail: true } + })) + storeOptions.modules.runtime.modules.spaces.getters.spaces = jest.fn(() => [ + { + id: '1', + fileId: '1', + driveType: 'personal', + getDriveAliasAndItem: () => 'personal/admin' + }, + { + id: '2', + driveType: 'project', + name: 'New space', + getDriveAliasAndItem: jest.fn() + } + ]) + const store = createStore(storeOptions) + const mocks = defaultComponentMocks({}) + + mocks.$clientService.webdav.search.mockResolvedValue({ + resources, + totalResults: resources.length + }) + + return getComposableWrapper( + () => { + const { search } = useSearch() + + return { + search + } + }, + { + mocks, + provide: mocks, + store + } + ) +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 20feb57eba4..afb7fa406ff 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -729,6 +729,9 @@ importers: design-system: specifier: workspace:@ownclouders/design-system@* version: link:../design-system + dompurify: + specifier: ^3.0.6 + version: 3.0.6 filesize: specifier: ^9.0.11 version: 9.0.11