From e9b9ca9ce13822d5299c26e48adea71babcd2eea Mon Sep 17 00:00:00 2001 From: Jannik Stehle Date: Thu, 2 May 2024 11:45:35 +0200 Subject: [PATCH] refactor: remove tokeninfo endpoint usage Removes the tokeninfo endpoint usage and uses the webdav response instead to retrieve all necessary information to resolve a public/internal link. The biggest change here is that we don't get the file id anymore when resolving an internal link. To solve this we need to login the user first and then redirect to the link resolving page again (only this time with an authenticated context, hence the new route `/i/...`). After that last redirect the authenticated user can now fetch all necessary information for resolving. See https://github.com/owncloud/ocis/issues/8858 for more detail. --- .../web-pkg/src/cern/composables/index.ts | 1 - .../src/cern/composables/useLoadTokenInfo.ts | 9 --- .../src/composables/tokenInfo/index.ts | 1 - .../composables/tokenInfo/useLoadTokenInfo.ts | 44 ----------- .../src/pages/resolvePublicLink.vue | 65 ++++++---------- packages/web-runtime/src/router/index.ts | 6 ++ .../unit/pages/resolvePublicLink.spec.ts | 75 ++++++++++++------- vite.cern.config.ts | 5 -- 8 files changed, 77 insertions(+), 129 deletions(-) delete mode 100644 packages/web-pkg/src/cern/composables/useLoadTokenInfo.ts delete mode 100644 packages/web-runtime/src/composables/tokenInfo/index.ts delete mode 100644 packages/web-runtime/src/composables/tokenInfo/useLoadTokenInfo.ts diff --git a/packages/web-pkg/src/cern/composables/index.ts b/packages/web-pkg/src/cern/composables/index.ts index 35b03f0f88e..92d3c2955b6 100644 --- a/packages/web-pkg/src/cern/composables/index.ts +++ b/packages/web-pkg/src/cern/composables/index.ts @@ -1,2 +1 @@ export * from './useGroupingSettings' -export * from './useLoadTokenInfo' diff --git a/packages/web-pkg/src/cern/composables/useLoadTokenInfo.ts b/packages/web-pkg/src/cern/composables/useLoadTokenInfo.ts deleted file mode 100644 index 9260f459171..00000000000 --- a/packages/web-pkg/src/cern/composables/useLoadTokenInfo.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { useTask } from 'vue-concurrency' - -export function useLoadTokenInfo() { - const loadTokenInfoTask = useTask(function* () { - return {} - }) - - return { loadTokenInfoTask } -} diff --git a/packages/web-runtime/src/composables/tokenInfo/index.ts b/packages/web-runtime/src/composables/tokenInfo/index.ts deleted file mode 100644 index e04af314e61..00000000000 --- a/packages/web-runtime/src/composables/tokenInfo/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './useLoadTokenInfo' diff --git a/packages/web-runtime/src/composables/tokenInfo/useLoadTokenInfo.ts b/packages/web-runtime/src/composables/tokenInfo/useLoadTokenInfo.ts deleted file mode 100644 index d6a4b04add2..00000000000 --- a/packages/web-runtime/src/composables/tokenInfo/useLoadTokenInfo.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { useTask } from 'vue-concurrency' -import convert from 'xml-js' -import { useClientService, useAuthStore, AuthStore } from '@ownclouders/web-pkg' -import { ClientService } from '@ownclouders/web-pkg' -import { urlJoin } from '@ownclouders/web-client' - -export interface LoadTokenInfoOptions { - clientService?: ClientService - authStore?: AuthStore -} - -export function useLoadTokenInfo(options: LoadTokenInfoOptions) { - const clientService = options.clientService || useClientService() - const authStore = options.authStore || useAuthStore() - - const loadTokenInfoTask = useTask(function* (signal, token: string) { - try { - const url = authStore.userContextReady - ? 'ocs/v1.php/apps/files_sharing/api/v1/tokeninfo/protected' - : 'ocs/v1.php/apps/files_sharing/api/v1/tokeninfo/unprotected' - - // FIXME: use graph endpoint as soon as it's available: https://github.com/owncloud/ocis/issues/8617 - const { data } = authStore.userContextReady - ? yield clientService.httpAuthenticated.get(urlJoin(url, token)) - : yield clientService.httpUnAuthenticated.get(urlJoin(url, token)) - - const parsedData = convert.xml2js(data, { compact: true, nativeType: false }) as any - const tokenInfo = parsedData.ocs.data - - return { - id: tokenInfo.id._text, - link_url: tokenInfo.link_url._text, - alias_link: tokenInfo.alias_link._text === 'true', - password_protected: tokenInfo.password_protected._text === 'true', - token - } - } catch (e) { - // backend doesn't support the token info endpoint - return {} - } - }) - - return { loadTokenInfoTask } -} diff --git a/packages/web-runtime/src/pages/resolvePublicLink.vue b/packages/web-runtime/src/pages/resolvePublicLink.vue index 1d31b18e02d..082ebb91699 100644 --- a/packages/web-runtime/src/pages/resolvePublicLink.vue +++ b/packages/web-runtime/src/pages/resolvePublicLink.vue @@ -80,10 +80,7 @@ import { isPublicSpaceResource, PublicSpaceResource } from '@ownclouders/web-client' -import isEmpty from 'lodash-es/isEmpty' import { useGettext } from 'vue3-gettext' -// full import is needed here so it can be overwritten via CERN config -import { useLoadTokenInfo } from 'web-runtime/src/composables/tokenInfo' import { urlJoin } from '@ownclouders/web-client' import { RouteLocationNamedRaw } from 'vue-router' import { dirname } from 'path' @@ -132,26 +129,27 @@ export default defineComponent({ return queryItemAsString(unref(detailsQuery)) }) - // token info - const { loadTokenInfoTask } = useLoadTokenInfo({ clientService, authStore }) - const tokenInfo = ref(null) + const isPasswordRequired = ref(false) + const isInternalLink = ref(false) - // generic public link loading - const isPasswordRequired = ref() - const isPasswordRequiredTask = useTask(function* () { - if (!isEmpty(unref(tokenInfo))) { - return unref(tokenInfo).password_protected - } + const loadLinkMetaDataTask = useTask(function* () { try { let space: PublicSpaceResource = { ...unref(publicLinkSpace), publicLinkPassword: null } yield clientService.webdav.getFileInfo(space) - return false } catch (error) { if (error.statusCode === 401) { - return true + if (error.message === "No 'Authorization: Basic' header found") { + isPasswordRequired.value = true + } + + if (error.message === "No 'Authorization: Bearer' header found") { + isInternalLink.value = true + } + + return } if (error.statusCode === 404) { throw new Error($gettext('The resource could not be located, it may not exist anymore.')) @@ -182,25 +180,13 @@ export default defineComponent({ return false }) - // resolve public link. resolve into authenticated context if possible. - const redirectToPrivateLink = (fileId: string | number) => { - return router.push({ - name: 'resolvePrivateLink', - params: { fileId: `${fileId}` }, - ...(unref(details) && { - query: { - details: unref(details) - } - }) - }) - } const resolvePublicLinkTask = useTask(function* (signal, passwordRequired: boolean) { if (unref(isOcmLink) && !configStore.options.ocm.openRemotely) { throw new Error($gettext('Opening files from remote is disabled')) } - if (!isEmpty(unref(tokenInfo)) && unref(tokenInfo)?.alias_link) { - redirectToPrivateLink(unref(tokenInfo).id) + if (unref(isInternalLink)) { + router.push({ name: 'login', query: { redirectUrl: `/i/${unref(token)}` } }) return } @@ -278,16 +264,11 @@ export default defineComponent({ router.push(targetLocation) }) - const isLoading = computed(() => { + const isLoading = computed(() => { if (unref(errorMessage)) { return false } - if ( - loadTokenInfoTask.isRunning || - !loadTokenInfoTask.last || - isPasswordRequiredTask.isRunning || - !isPasswordRequiredTask.last - ) { + if (loadLinkMetaDataTask.isRunning || !loadLinkMetaDataTask.last) { return true } if (!unref(isPasswordRequired)) { @@ -299,11 +280,9 @@ export default defineComponent({ if (resolvePublicLinkTask.isError && resolvePublicLinkTask.last.error.statusCode !== 401) { return resolvePublicLinkTask.last.error.message } - if (loadTokenInfoTask.isError) { - return loadTokenInfoTask.last.error.message - } - if (isPasswordRequiredTask.isError) { - return isPasswordRequiredTask.last.error.message + + if (loadLinkMetaDataTask.isError) { + return loadLinkMetaDataTask.last.error.message } return null }) @@ -314,8 +293,7 @@ export default defineComponent({ return } - tokenInfo.value = await loadTokenInfoTask.perform(unref(token)) - isPasswordRequired.value = await isPasswordRequiredTask.perform() + await loadLinkMetaDataTask.perform() if (!unref(isPasswordRequired)) { await resolvePublicLinkTask.perform(false) } @@ -342,9 +320,8 @@ export default defineComponent({ isLoading, errorMessage, footerSlogan, - loadTokenInfoTask, resolvePublicLinkTask, - isPasswordRequiredTask + loadLinkMetaDataTask } } }) diff --git a/packages/web-runtime/src/router/index.ts b/packages/web-runtime/src/router/index.ts index 310636a3ed8..be3e1021cc4 100644 --- a/packages/web-runtime/src/router/index.ts +++ b/packages/web-runtime/src/router/index.ts @@ -64,6 +64,12 @@ const routes = [ component: ResolvePublicLinkPage, meta: { title: $gettext('Public link'), authContext: 'anonymous' } }, + { + path: '/i/:token/:driveAliasAndItem(.*)?', + name: 'resolveInternalLink', + component: ResolvePublicLinkPage, + meta: { title: $gettext('Internal link'), authContext: 'user' } + }, { path: '/o/:token/:driveAliasAndItem(.*)?', name: 'resolvePublicOcmLink', diff --git a/packages/web-runtime/tests/unit/pages/resolvePublicLink.spec.ts b/packages/web-runtime/tests/unit/pages/resolvePublicLink.spec.ts index d077c6318f3..7403bf51092 100644 --- a/packages/web-runtime/tests/unit/pages/resolvePublicLink.spec.ts +++ b/packages/web-runtime/tests/unit/pages/resolvePublicLink.spec.ts @@ -1,14 +1,17 @@ import ResolvePublicLink from '../../../src/pages/resolvePublicLink.vue' -import { defaultPlugins, defaultComponentMocks, shallowMount, nextTicks } from 'web-test-helpers' +import { defaultPlugins, defaultComponentMocks, shallowMount } from 'web-test-helpers' import { mockDeep } from 'vitest-mock-extended' -import { CapabilityStore, ClientService } from '@ownclouders/web-pkg' -import { SpaceResource } from '@ownclouders/web-client' +import { CapabilityStore, ClientService, useRouteParam } from '@ownclouders/web-pkg' +import { HttpError, SpaceResource } from '@ownclouders/web-client' import { authService } from 'web-runtime/src/services/auth' -import { useLoadTokenInfo } from '../../../src/composables/tokenInfo' -import { Task } from 'vue-concurrency' +import { ref } from 'vue' vi.mock('web-runtime/src/services/auth') -vi.mock('web-runtime/src/composables/tokenInfo') + +vi.mock('@ownclouders/web-pkg', async (importOriginal) => ({ + ...(await importOriginal()), + useRouteParam: vi.fn() +})) const selectors = { cardFooter: '.oc-card-footer', @@ -30,61 +33,83 @@ describe('resolvePublicLink', () => { describe('password required form', () => { it('should display if password is required', async () => { const { wrapper } = getWrapper({ passwordRequired: true }) - await wrapper.vm.isPasswordRequiredTask.last - await nextTicks(4) + await wrapper.vm.loadLinkMetaDataTask.last + expect(wrapper.find('form').html()).toMatchSnapshot() }) describe('submit button', () => { it('should be set as disabled if "password" is empty', async () => { const { wrapper } = getWrapper({ passwordRequired: true }) - await wrapper.vm.isPasswordRequiredTask.last - await nextTicks(4) + await wrapper.vm.loadLinkMetaDataTask.last + expect(wrapper.find(selectors.submitButton).attributes().disabled).toBe('true') }) it('should be set as enabled if "password" is not empty', async () => { const { wrapper } = getWrapper({ passwordRequired: true }) - await wrapper.vm.isPasswordRequiredTask.last - await nextTicks(4) + await wrapper.vm.loadLinkMetaDataTask.last wrapper.vm.password = 'password' await wrapper.vm.$nextTick() + expect(wrapper.find(selectors.submitButton).attributes().disabled).toBe('false') }) it('should resolve the public link on click', async () => { const resolvePublicLinkSpy = vi.spyOn(authService, 'resolvePublicLink') const { wrapper } = getWrapper({ passwordRequired: true }) - await wrapper.vm.isPasswordRequiredTask.last - await nextTicks(4) + await wrapper.vm.loadLinkMetaDataTask.last + wrapper.vm.password = 'password' await wrapper.vm.$nextTick() await wrapper.find(selectors.submitButton).trigger('submit') await wrapper.vm.resolvePublicLinkTask.last + expect(resolvePublicLinkSpy).toHaveBeenCalled() }) }) }) -}) + describe('internal link', () => { + it('redirects the user to the login page', async () => { + const { wrapper, mocks } = getWrapper({ isInternalLink: true }) + await wrapper.vm.loadLinkMetaDataTask.last -function getWrapper({ passwordRequired = false } = {}) { - const tokenInfo = { password_protected: passwordRequired } as any - vi.mocked(useLoadTokenInfo).mockReturnValue({ - loadTokenInfoTask: mockDeep>({ - perform: () => tokenInfo, - isRunning: false, - isError: false + expect(mocks.$router.push).toHaveBeenCalledWith({ + name: 'login', + query: { redirectUrl: '/i/token' } + }) }) }) +}) +function getWrapper({ + passwordRequired = false, + isInternalLink = false +}: { passwordRequired?: boolean; isInternalLink?: boolean } = {}) { const $clientService = mockDeep() - $clientService.webdav.getFileInfo.mockResolvedValue( - mockDeep({ driveType: 'public' }) - ) + const spaceResource = mockDeep({ driveType: 'public' }) + + // loadLinkMetaDataTask response + if (passwordRequired) { + $clientService.webdav.getFileInfo.mockRejectedValueOnce( + new HttpError("No 'Authorization: Basic' header found", undefined, 401) + ) + } else if (isInternalLink) { + $clientService.webdav.getFileInfo.mockRejectedValueOnce( + new HttpError("No 'Authorization: Bearer' header found", undefined, 401) + ) + } else { + $clientService.webdav.getFileInfo.mockResolvedValueOnce(spaceResource) + } + + $clientService.webdav.getFileInfo.mockResolvedValueOnce(spaceResource) const mocks = { ...defaultComponentMocks(), $clientService } const capabilities = { files_sharing: { federation: { incoming: true, outgoing: true } } } satisfies Partial + vi.mocked(useRouteParam).mockReturnValue(ref('token')) + return { + mocks, wrapper: shallowMount(ResolvePublicLink, { global: { plugins: [...defaultPlugins({ piniaOptions: { capabilityState: { capabilities } } })], diff --git a/vite.cern.config.ts b/vite.cern.config.ts index 7afc4c16d5d..35f76ac939b 100644 --- a/vite.cern.config.ts +++ b/vite.cern.config.ts @@ -29,11 +29,6 @@ export default defineConfig(async (args) => { projectRootDir, 'packages/web-pkg/src/cern/components/CollapsibleOcTable.vue' ) - // token info request - ;(config.resolve.alias as any)['web-runtime/src/composables/tokenInfo'] = join( - projectRootDir, - 'packages/web-pkg/src/cern/composables/useLoadTokenInfo' - ) // create space component ;(config.resolve.alias as any)['../../components/AppBar/CreateSpace.vue'] = join( projectRootDir,