From ef0d06f31f66cd2ed6a2a813fe1bd2e96838ab8a Mon Sep 17 00:00:00 2001 From: "Grigorii K. Shartsev" Date: Wed, 20 Mar 2024 01:34:18 +0100 Subject: [PATCH 1/2] test(autolink): add tests for getRoute Signed-off-by: Grigorii K. Shartsev --- .../components/NcRichText/autoLink.spec.js | 159 ++++++++++++++++++ 1 file changed, 159 insertions(+) create mode 100644 tests/unit/components/NcRichText/autoLink.spec.js diff --git a/tests/unit/components/NcRichText/autoLink.spec.js b/tests/unit/components/NcRichText/autoLink.spec.js new file mode 100644 index 0000000000..bc44cff4b1 --- /dev/null +++ b/tests/unit/components/NcRichText/autoLink.spec.js @@ -0,0 +1,159 @@ +/** + * @copyright Copyright (c) 2024 Grigorii K. Shartsev + * + * @author Grigorii K. Shartsev + * + * @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 . + * + */ + +import { expect, describe, it, jest } from '@jest/globals' +import { getRoute } from '../../../../src/components/NcRichText/autolink.js' +import VueRouter from 'vue-router' +import { getBaseUrl, getRootUrl } from '@nextcloud/router' + +jest.mock('@nextcloud/router') + +describe('autoLink', () => { + describe('getRoute', () => { + describe.each([ + ['an absolute link', 'https://cloud.ltd'], + ['a relative link', ''], + ])('for %s', (_, origin) => { + describe.each([ + ['with base /nextcloud', '/nextcloud'], + ['on server root', ''], + ])('%s', (_, root) => { + describe.each([ + ['with', '/index.php'], + ['without', ''], + ])('%s /index.php in link', (_, indexPhp) => { + const linkBase = origin + root + indexPhp + beforeAll(() => { + getBaseUrl.mockReturnValue(`https://cloud.ltd${root}`) + getRootUrl.mockReturnValue(root) + }) + + describe.each([ + ['with', '/index.php'], + ['without', ''], + ])('%s /index.php in router base', (_, indexPhpInRouterBase) => { + const routerBase = `${root}${indexPhpInRouterBase}` + + it.each([ + [`${linkBase}/apps/test/foo`, '/foo'], + [`${linkBase}/apps/test/foo/`, '/foo/'], + [`${linkBase}/apps/test/bar/1`, '/bar/1'], + + ['https://external.ltd/nextcloud/index.php/apps/test/foo', null], // Different origin + ['https://cloud.ltd/external/index.php/apps/test/foo/', null], // Different base + ['https://cloud.ltd/nextcloud/index.php/apps/not-router-base/', null], // Different router base + ['https://cloud.ltd/nextcloud/apps/test/baz', null], // No matching route + ])('should get route %s => %s', (link, expectedRoute) => { + const routerTest = new VueRouter({ + mode: 'history', + base: `${routerBase}/apps/test`, + routes: [ + { path: '/foo', name: 'foo' }, + { path: '/bar/:param', name: 'bar' }, + ], + }) + expect(getRoute(routerTest, link)).toBe(expectedRoute) + }) + + it.each([ + [`${linkBase}/apps/files/`, '/files'], + [`${linkBase}/apps/files/favorites/1`, '/favorites/1'], + [`${linkBase}/apps/files/files/1?fileid=2#c`, '/files/1?fileid=2#c'], // With query and hash + [`${linkBase}/apps/files/files/1?dir=server/lib/index.php#c`, '/files/1?dir=server%2Flib%2Findex.php#c'], // With index.php in query + + ])('should get route for Files: %s => %s', (link, expectedRoute) => { + const routerFiles = new VueRouter({ + mode: 'history', + base: `${routerBase}/apps/files`, + routes: [ + { path: '/', name: 'root', redirect: '/files' }, + { path: '/:view/:fileid(\\d+)?', name: 'fileslist' }, + ], + }) + + expect(getRoute(routerFiles, link)).toBe(expectedRoute) + }) + + it.each([ + [`${linkBase}/apps/spreed?callTo=alice`, '/apps/spreed?callTo=alice'], + [`${linkBase}/call/abc123ef#message_123`, '/call/abc123ef#message_123'], + [`${linkBase}/apps/files`, null], + [`${linkBase}`, null], + ])('should get route for Talk: %s => %s', (link, expectedRoute) => { + const routerTalk = new VueRouter({ + mode: 'history', + base: `${routerBase}`, + routes: [ + { path: '/apps/spreed', name: 'root' }, + { path: '/call/:id', name: 'call' }, + ], + }) + expect(getRoute(routerTalk, link)).toBe(expectedRoute) + }) + + it.each([ + [`${linkBase}/settings/apps`, '/apps'], + [`${linkBase}/apps/files`, null], + ])('should get route for Settings: %s => %s', (link, expectedRoute) => { + const routerSettings = new VueRouter({ + mode: 'history', + base: `${routerBase}/settings`, + routes: [ + { path: '/apps', name: 'apps' }, + ], + }) + + expect(getRoute(routerSettings, link)).toBe(expectedRoute) + }) + }) + }) + }) + }) + + // getRoute doesn't have to guarantee Talk Desktop compatiblity, but checking just in case + describe('with Talk Desktop router - no router base and invalid getRootUrl', () => { + it.each([ + ['https://cloud.ltd/nextcloud/index.php/apps/spreed?callTo=alice'], + ['https://cloud.ltd/nextcloud/index.php/call/abc123ef'], + ['https://cloud.ltd/nextcloud/index.php/apps/files'], + ['https://cloud.ltd/nextcloud/'], + ])('should not get route for %s', (link) => { + // On Talk Desktop both Base and Root URL returns an absolute path because there is no location + getBaseUrl.mockReturnValue('https://cloud.ltd/nextcloud') + getRootUrl.mockReturnValue('https://cloud.ltd/nextcloud') + + const routerTalkDesktop = new VueRouter({ + // On Talk Desktop, we use hash mode, because it works on file:// protocol + mode: 'hash', + // On Talk Desktop we have no base because we open an HTML document as a file + base: '', + routes: [ + { path: '/apps/spreed', name: 'root' }, + { path: '/call/:id', name: 'call' }, + ], + }) + + expect(getRoute(routerTalkDesktop, link)).toBe(null) + }) + }) + }) +}) From e2da9733c860127372aea721a6b263dd0b8eb92f Mon Sep 17 00:00:00 2001 From: "Grigorii K. Shartsev" Date: Mon, 18 Mar 2024 20:24:39 +0100 Subject: [PATCH 2/2] fix(NcRichText): more strictly resolve vue router's path Signed-off-by: Grigorii K. Shartsev --- src/components/NcRichText/autolink.js | 84 +++++++++++++++++++++++---- 1 file changed, 73 insertions(+), 11 deletions(-) diff --git a/src/components/NcRichText/autolink.js b/src/components/NcRichText/autolink.js index 2776cfab05..d92b619185 100644 --- a/src/components/NcRichText/autolink.js +++ b/src/components/NcRichText/autolink.js @@ -1,8 +1,33 @@ +/** + * @copyright Copyright (c) 2022 Julius Härtl + * + * @author Julius Härtl + * @author Raimund Schlüßler + * @author Maksim Sukharev + * @author Grigorii K. Shartsev + * + * @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 . + * + */ + import { URL_PATTERN_AUTOLINK } from './helpers.js' import { visit, SKIP } from 'unist-util-visit' import { u } from 'unist-builder' -import { getBaseUrl } from '@nextcloud/router' +import { getBaseUrl, getRootUrl } from '@nextcloud/router' const NcLink = { name: 'NcLink', @@ -84,20 +109,57 @@ export const parseUrl = (text) => { return text } +/** + * Try to get path for router link from an absolute or relative URL. + * + * @param {import('vue-router').default} router - VueRouter instance of the router link + * @param {string} url - absolute URL to parse + * @return {string|null} a path that can be useed in the router link or null if this URL doesn't match this router config + * @example http://cloud.ltd/nextcloud/index.php/app/files/favorites?fileid=2#fragment => /files/favorites?fileid=2#fragment + */ export const getRoute = (router, url) => { - // Skip if Router is not defined in app, or baseUrl does not match - if (!router || !url.includes(getBaseUrl())) { + /** + * http://cloud.ltd /nextcloud /index.php/app/files /favorites?fileid=2#fragment + * |_____origin____|__________router-base__________|_________router-path________| + * |__________base____________| + * |___root___| + */ + + // Router is not defined in the app => not an app route + if (!router) { return null } - const regexArray = router.getRoutes() - // route.regex matches only complete string (^.$), need to remove these characters - .map(route => new RegExp(route.regex.source.slice(1, -1), route.regex.flags)) + const isAbsoluteURL = /^https?:\/\//.test(url) - for (const regex of regexArray) { - const match = url.search(regex) - if (match !== -1) { - return url.slice(match) - } + // URL is not a link to this Nextcloud server instance => not an app route + if ((isAbsoluteURL && !url.startsWith(getBaseUrl())) || (!isAbsoluteURL && !url.startsWith(getRootUrl()))) { + return null + } + + // Vue 3: router.options.history.base + const routerBase = router.history.base + + const urlWithoutOrigin = isAbsoluteURL ? url.slice(new URL(url).origin.length) : url + + // Remove index.php - it is optional in general case in both, VueRouter base and the URL + const urlWithoutOriginAndIndexPhp = url.startsWith((isAbsoluteURL ? getBaseUrl() : getRootUrl()) + '/index.php') ? urlWithoutOrigin.replace('/index.php', '') : urlWithoutOrigin + const routerBaseWithoutIndexPhp = routerBase.replace('/index.php', '') + + // This URL is not a part of this router by base + if (!urlWithoutOriginAndIndexPhp.startsWith(routerBaseWithoutIndexPhp)) { + return null + } + + // Root route may have an empty '' path, fallback to '/' + const routerPath = urlWithoutOriginAndIndexPhp.replace(routerBaseWithoutIndexPhp, '') || '/' + + // Check if there is actually matching route in the router for this path + const route = router.resolve(routerPath).route + + if (!route.matched.length) { + return null } + + return route.fullPath }