Skip to content

Commit

Permalink
Merge pull request #5419 from nextcloud-libraries/fix/NcRichText--aut…
Browse files Browse the repository at this point in the history
…o-link-resolve

fix(NcRichText): more strictly resolve vue router's path
  • Loading branch information
susnux committed Apr 10, 2024
2 parents 1d94b62 + e2da973 commit a8c6b73
Show file tree
Hide file tree
Showing 2 changed files with 232 additions and 11 deletions.
84 changes: 73 additions & 11 deletions src/components/NcRichText/autolink.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,33 @@
/**
* @copyright Copyright (c) 2022 Julius Härtl <jus@bitgrid.net>
*
* @author Julius Härtl <jus@bitgrid.net>
* @author Raimund Schlüßler <raimund.schluessler@mailbox.org>
* @author Maksim Sukharev <antreesy.web@gmail.com>
* @author Grigorii K. Shartsev <me@shgk.me>
*
* @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 { 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',
Expand Down Expand Up @@ -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
}
159 changes: 159 additions & 0 deletions tests/unit/components/NcRichText/autoLink.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
/**
* @copyright Copyright (c) 2024 Grigorii K. Shartsev <me@shgk.me>
*
* @author Grigorii K. Shartsev <me@shgk.me>
*
* @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, 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)
})
})
})
})

0 comments on commit a8c6b73

Please sign in to comment.