Skip to content

Commit a8c6b73

Browse files
authored
Merge pull request #5419 from nextcloud-libraries/fix/NcRichText--auto-link-resolve
fix(NcRichText): more strictly resolve vue router's path
2 parents 1d94b62 + e2da973 commit a8c6b73

File tree

2 files changed

+232
-11
lines changed

2 files changed

+232
-11
lines changed

src/components/NcRichText/autolink.js

+73-11
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,33 @@
1+
/**
2+
* @copyright Copyright (c) 2022 Julius Härtl <[email protected]>
3+
*
4+
* @author Julius Härtl <[email protected]>
5+
* @author Raimund Schlüßler <[email protected]>
6+
* @author Maksim Sukharev <[email protected]>
7+
* @author Grigorii K. Shartsev <[email protected]>
8+
*
9+
* @license AGPL-3.0-or-later
10+
*
11+
* This program is free software: you can redistribute it and/or modify
12+
* it under the terms of the GNU Affero General Public License as
13+
* published by the Free Software Foundation, either version 3 of the
14+
* License, or (at your option) any later version.
15+
*
16+
* This program is distributed in the hope that it will be useful,
17+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
18+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
19+
* GNU Affero General Public License for more details.
20+
*
21+
* You should have received a copy of the GNU Affero General Public License
22+
* along with this program. If not, see <http://www.gnu.org/licenses/>.
23+
*
24+
*/
25+
126
import { URL_PATTERN_AUTOLINK } from './helpers.js'
227

328
import { visit, SKIP } from 'unist-util-visit'
429
import { u } from 'unist-builder'
5-
import { getBaseUrl } from '@nextcloud/router'
30+
import { getBaseUrl, getRootUrl } from '@nextcloud/router'
631

732
const NcLink = {
833
name: 'NcLink',
@@ -84,20 +109,57 @@ export const parseUrl = (text) => {
84109
return text
85110
}
86111

112+
/**
113+
* Try to get path for router link from an absolute or relative URL.
114+
*
115+
* @param {import('vue-router').default} router - VueRouter instance of the router link
116+
* @param {string} url - absolute URL to parse
117+
* @return {string|null} a path that can be useed in the router link or null if this URL doesn't match this router config
118+
* @example http://cloud.ltd/nextcloud/index.php/app/files/favorites?fileid=2#fragment => /files/favorites?fileid=2#fragment
119+
*/
87120
export const getRoute = (router, url) => {
88-
// Skip if Router is not defined in app, or baseUrl does not match
89-
if (!router || !url.includes(getBaseUrl())) {
121+
/**
122+
* http://cloud.ltd /nextcloud /index.php/app/files /favorites?fileid=2#fragment
123+
* |_____origin____|__________router-base__________|_________router-path________|
124+
* |__________base____________|
125+
* |___root___|
126+
*/
127+
128+
// Router is not defined in the app => not an app route
129+
if (!router) {
90130
return null
91131
}
92132

93-
const regexArray = router.getRoutes()
94-
// route.regex matches only complete string (^.$), need to remove these characters
95-
.map(route => new RegExp(route.regex.source.slice(1, -1), route.regex.flags))
133+
const isAbsoluteURL = /^https?:\/\//.test(url)
96134

97-
for (const regex of regexArray) {
98-
const match = url.search(regex)
99-
if (match !== -1) {
100-
return url.slice(match)
101-
}
135+
// URL is not a link to this Nextcloud server instance => not an app route
136+
if ((isAbsoluteURL && !url.startsWith(getBaseUrl())) || (!isAbsoluteURL && !url.startsWith(getRootUrl()))) {
137+
return null
138+
}
139+
140+
// Vue 3: router.options.history.base
141+
const routerBase = router.history.base
142+
143+
const urlWithoutOrigin = isAbsoluteURL ? url.slice(new URL(url).origin.length) : url
144+
145+
// Remove index.php - it is optional in general case in both, VueRouter base and the URL
146+
const urlWithoutOriginAndIndexPhp = url.startsWith((isAbsoluteURL ? getBaseUrl() : getRootUrl()) + '/index.php') ? urlWithoutOrigin.replace('/index.php', '') : urlWithoutOrigin
147+
const routerBaseWithoutIndexPhp = routerBase.replace('/index.php', '')
148+
149+
// This URL is not a part of this router by base
150+
if (!urlWithoutOriginAndIndexPhp.startsWith(routerBaseWithoutIndexPhp)) {
151+
return null
152+
}
153+
154+
// Root route may have an empty '' path, fallback to '/'
155+
const routerPath = urlWithoutOriginAndIndexPhp.replace(routerBaseWithoutIndexPhp, '') || '/'
156+
157+
// Check if there is actually matching route in the router for this path
158+
const route = router.resolve(routerPath).route
159+
160+
if (!route.matched.length) {
161+
return null
102162
}
163+
164+
return route.fullPath
103165
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
/**
2+
* @copyright Copyright (c) 2024 Grigorii K. Shartsev <[email protected]>
3+
*
4+
* @author Grigorii K. Shartsev <[email protected]>
5+
*
6+
* @license AGPL-3.0-or-later
7+
*
8+
* This program is free software: you can redistribute it and/or modify
9+
* it under the terms of the GNU Affero General Public License as
10+
* published by the Free Software Foundation, either version 3 of the
11+
* License, or (at your option) any later version.
12+
*
13+
* This program is distributed in the hope that it will be useful,
14+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
15+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
16+
* GNU Affero General Public License for more details.
17+
*
18+
* You should have received a copy of the GNU Affero General Public License
19+
* along with this program. If not, see <http://www.gnu.org/licenses/>.
20+
*
21+
*/
22+
23+
import { expect, describe, it, jest } from '@jest/globals'
24+
import { getRoute } from '../../../../src/components/NcRichText/autolink.js'
25+
import VueRouter from 'vue-router'
26+
import { getBaseUrl, getRootUrl } from '@nextcloud/router'
27+
28+
jest.mock('@nextcloud/router')
29+
30+
describe('autoLink', () => {
31+
describe('getRoute', () => {
32+
describe.each([
33+
['an absolute link', 'https://cloud.ltd'],
34+
['a relative link', ''],
35+
])('for %s', (_, origin) => {
36+
describe.each([
37+
['with base /nextcloud', '/nextcloud'],
38+
['on server root', ''],
39+
])('%s', (_, root) => {
40+
describe.each([
41+
['with', '/index.php'],
42+
['without', ''],
43+
])('%s /index.php in link', (_, indexPhp) => {
44+
const linkBase = origin + root + indexPhp
45+
beforeAll(() => {
46+
getBaseUrl.mockReturnValue(`https://cloud.ltd${root}`)
47+
getRootUrl.mockReturnValue(root)
48+
})
49+
50+
describe.each([
51+
['with', '/index.php'],
52+
['without', ''],
53+
])('%s /index.php in router base', (_, indexPhpInRouterBase) => {
54+
const routerBase = `${root}${indexPhpInRouterBase}`
55+
56+
it.each([
57+
[`${linkBase}/apps/test/foo`, '/foo'],
58+
[`${linkBase}/apps/test/foo/`, '/foo/'],
59+
[`${linkBase}/apps/test/bar/1`, '/bar/1'],
60+
61+
['https://external.ltd/nextcloud/index.php/apps/test/foo', null], // Different origin
62+
['https://cloud.ltd/external/index.php/apps/test/foo/', null], // Different base
63+
['https://cloud.ltd/nextcloud/index.php/apps/not-router-base/', null], // Different router base
64+
['https://cloud.ltd/nextcloud/apps/test/baz', null], // No matching route
65+
])('should get route %s => %s', (link, expectedRoute) => {
66+
const routerTest = new VueRouter({
67+
mode: 'history',
68+
base: `${routerBase}/apps/test`,
69+
routes: [
70+
{ path: '/foo', name: 'foo' },
71+
{ path: '/bar/:param', name: 'bar' },
72+
],
73+
})
74+
expect(getRoute(routerTest, link)).toBe(expectedRoute)
75+
})
76+
77+
it.each([
78+
[`${linkBase}/apps/files/`, '/files'],
79+
[`${linkBase}/apps/files/favorites/1`, '/favorites/1'],
80+
[`${linkBase}/apps/files/files/1?fileid=2#c`, '/files/1?fileid=2#c'], // With query and hash
81+
[`${linkBase}/apps/files/files/1?dir=server/lib/index.php#c`, '/files/1?dir=server%2Flib%2Findex.php#c'], // With index.php in query
82+
83+
])('should get route for Files: %s => %s', (link, expectedRoute) => {
84+
const routerFiles = new VueRouter({
85+
mode: 'history',
86+
base: `${routerBase}/apps/files`,
87+
routes: [
88+
{ path: '/', name: 'root', redirect: '/files' },
89+
{ path: '/:view/:fileid(\\d+)?', name: 'fileslist' },
90+
],
91+
})
92+
93+
expect(getRoute(routerFiles, link)).toBe(expectedRoute)
94+
})
95+
96+
it.each([
97+
[`${linkBase}/apps/spreed?callTo=alice`, '/apps/spreed?callTo=alice'],
98+
[`${linkBase}/call/abc123ef#message_123`, '/call/abc123ef#message_123'],
99+
[`${linkBase}/apps/files`, null],
100+
[`${linkBase}`, null],
101+
])('should get route for Talk: %s => %s', (link, expectedRoute) => {
102+
const routerTalk = new VueRouter({
103+
mode: 'history',
104+
base: `${routerBase}`,
105+
routes: [
106+
{ path: '/apps/spreed', name: 'root' },
107+
{ path: '/call/:id', name: 'call' },
108+
],
109+
})
110+
expect(getRoute(routerTalk, link)).toBe(expectedRoute)
111+
})
112+
113+
it.each([
114+
[`${linkBase}/settings/apps`, '/apps'],
115+
[`${linkBase}/apps/files`, null],
116+
])('should get route for Settings: %s => %s', (link, expectedRoute) => {
117+
const routerSettings = new VueRouter({
118+
mode: 'history',
119+
base: `${routerBase}/settings`,
120+
routes: [
121+
{ path: '/apps', name: 'apps' },
122+
],
123+
})
124+
125+
expect(getRoute(routerSettings, link)).toBe(expectedRoute)
126+
})
127+
})
128+
})
129+
})
130+
})
131+
132+
// getRoute doesn't have to guarantee Talk Desktop compatiblity, but checking just in case
133+
describe('with Talk Desktop router - no router base and invalid getRootUrl', () => {
134+
it.each([
135+
['https://cloud.ltd/nextcloud/index.php/apps/spreed?callTo=alice'],
136+
['https://cloud.ltd/nextcloud/index.php/call/abc123ef'],
137+
['https://cloud.ltd/nextcloud/index.php/apps/files'],
138+
['https://cloud.ltd/nextcloud/'],
139+
])('should not get route for %s', (link) => {
140+
// On Talk Desktop both Base and Root URL returns an absolute path because there is no location
141+
getBaseUrl.mockReturnValue('https://cloud.ltd/nextcloud')
142+
getRootUrl.mockReturnValue('https://cloud.ltd/nextcloud')
143+
144+
const routerTalkDesktop = new VueRouter({
145+
// On Talk Desktop, we use hash mode, because it works on file:// protocol
146+
mode: 'hash',
147+
// On Talk Desktop we have no base because we open an HTML document as a file
148+
base: '',
149+
routes: [
150+
{ path: '/apps/spreed', name: 'root' },
151+
{ path: '/call/:id', name: 'call' },
152+
],
153+
})
154+
155+
expect(getRoute(routerTalkDesktop, link)).toBe(null)
156+
})
157+
})
158+
})
159+
})

0 commit comments

Comments
 (0)