Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
143 changes: 143 additions & 0 deletions webapp/src/desktopHistory.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
// Copyright (c) 2020-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.

import {History} from 'history'

import {Utils} from './utils'
import {SuiteWindow} from './types/index'

import {boardsRouteBase, customHistory, doBrowserHistoryPush, handleBrowserHistoryMessage, handleBrowserHistoryPush} from './desktopHistory'

const windowAny = (window as SuiteWindow)

describe('desktopHistory', () => {
const originalFrontendBaseURL = windowAny.frontendBaseURL

beforeEach(() => {
jest.restoreAllMocks()
jest.spyOn(Utils, 'log').mockImplementation(() => {})
delete windowAny.desktopAPI
windowAny.frontendBaseURL = '/boards'
})

afterAll(() => {
windowAny.frontendBaseURL = originalFrontendBaseURL
})

describe('handleBrowserHistoryPush', () => {
const makeHistory = () => ({replace: jest.fn()} as unknown as History)

test('ignores an empty path', () => {
const history = makeHistory()
handleBrowserHistoryPush('', history)
expect(history.replace).not.toHaveBeenCalled()
})

test('ignores a path that still carries the server subpath', () => {
const history = makeHistory()
handleBrowserHistoryPush('/company/boards/team/team-id', history)
expect(history.replace).not.toHaveBeenCalled()
})

test('strips the boards route base before navigating', () => {
const history = makeHistory()
handleBrowserHistoryPush(`${boardsRouteBase}/team/team-id`, history)
expect(history.replace).toHaveBeenCalledWith('/team/team-id')
})

test('navigates to the root for the bare boards route', () => {
const history = makeHistory()
handleBrowserHistoryPush(boardsRouteBase, history)
expect(history.replace).toHaveBeenCalledWith('/')
})

test('ignores a route that only shares the boards prefix', () => {
const history = makeHistory()
handleBrowserHistoryPush('/boards-legacy/team/team-id', history)
expect(history.replace).not.toHaveBeenCalled()
})
})

describe('handleBrowserHistoryMessage', () => {
const makeHistory = () => ({replace: jest.fn()} as unknown as History)
const sameOrigin = window.location.origin

test('forwards a valid same-origin boards path', () => {
const history = makeHistory()
const event = {origin: sameOrigin, data: {message: {pathName: '/boards/team/team-id'}}} as MessageEvent
handleBrowserHistoryMessage(event, history)
expect(history.replace).toHaveBeenCalledWith('/team/team-id')
})

test('ignores messages from a different origin', () => {
const history = makeHistory()
const event = {origin: 'https://evil.example', data: {message: {pathName: '/boards/team/team-id'}}} as MessageEvent
handleBrowserHistoryMessage(event, history)
expect(history.replace).not.toHaveBeenCalled()
})

test('ignores a null payload without throwing', () => {
const history = makeHistory()
const event = {origin: sameOrigin, data: null} as MessageEvent
expect(() => handleBrowserHistoryMessage(event, history)).not.toThrow()
expect(history.replace).not.toHaveBeenCalled()
})

test('ignores a non-string pathName', () => {
const history = makeHistory()
const event = {origin: sameOrigin, data: {message: {pathName: 42}}} as unknown as MessageEvent
handleBrowserHistoryMessage(event, history)
expect(history.replace).not.toHaveBeenCalled()
})
})

describe('doBrowserHistoryPush', () => {
test('uses the desktop API when available', () => {
const sendBrowserHistoryPush = jest.fn()
windowAny.desktopAPI = {sendBrowserHistoryPush}

doBrowserHistoryPush('/boards/team/team-id')

expect(sendBrowserHistoryPush).toHaveBeenCalledWith('/boards/team/team-id')
})

test('falls back to postMessage when the desktop API is missing', () => {
const postMessage = jest.spyOn(window, 'postMessage').mockImplementation(() => {})

doBrowserHistoryPush('/boards/team/team-id')

expect(postMessage).toHaveBeenCalledWith(
{type: 'browser-history-push', message: {path: '/boards/team/team-id'}},
window.location.origin,
)
})
})

describe('customHistory push on desktop', () => {
test('sends a subpath-relative path so the subpath is never leaked (MM-67542)', () => {
jest.spyOn(Utils, 'isDesktop').mockReturnValue(true)
const sendBrowserHistoryPush = jest.fn()
windowAny.desktopAPI = {sendBrowserHistoryPush}

// Simulate a subpath deployment: the frontend base URL includes the subpath.
windowAny.frontendBaseURL = '/company/boards'

const history = customHistory()
history.push('/team/team-id')

expect(sendBrowserHistoryPush).toHaveBeenCalledWith('/boards/team/team-id')
expect(sendBrowserHistoryPush).not.toHaveBeenCalledWith('/company/boards/team/team-id')
})

test('does not notify the desktop app when not running in desktop', () => {
jest.spyOn(Utils, 'isDesktop').mockReturnValue(false)
const sendBrowserHistoryPush = jest.fn()
windowAny.desktopAPI = {sendBrowserHistoryPush}

const history = customHistory()
history.push('/team/team-id')

expect(sendBrowserHistoryPush).not.toHaveBeenCalled()
})
})
})
74 changes: 74 additions & 0 deletions webapp/src/desktopHistory.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
// Copyright (c) 2020-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.

import {createBrowserHistory, History} from 'history'

import {Utils} from './utils'
import {SuiteWindow} from './types/index'

const windowAny = (window as SuiteWindow)

// Boards is always mounted at this in-app route. Paths exchanged with the
// Desktop App must be relative to the server subpath (the history basename),
// exactly like the core web app. Prefixing the full frontendBaseURL here would
// leak the subpath and force the Desktop App to strip it (see MM-67542).
export const boardsRouteBase = '/boards'

export const doBrowserHistoryPush = (path: string): void => {
if (windowAny.desktopAPI?.sendBrowserHistoryPush) {
windowAny.desktopAPI.sendBrowserHistoryPush(path)
} else {
window.postMessage(
{
type: 'browser-history-push',
message: {path},
},
window.location.origin,
)
}
}

export const handleBrowserHistoryPush = (pathName: string, history: History): void => {
// Only navigate for the boards root or a path under it, so a route like
// `/boards-legacy/...` is not mistaken for a boards path.
if (!pathName || (pathName !== boardsRouteBase && !pathName.startsWith(`${boardsRouteBase}/`))) {
return
}

Utils.log(`Navigating Boards to ${pathName}`)
history.replace(pathName === boardsRouteBase ? '/' : pathName.slice(boardsRouteBase.length))
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

export const handleBrowserHistoryMessage = (event: MessageEvent, history: History): void => {
if (event.origin !== windowAny.location.origin) {
return
}

const pathName = event.data?.message?.pathName
if (typeof pathName === 'string') {
handleBrowserHistoryPush(pathName, history)
}
}

export function customHistory() {
const history = createBrowserHistory({basename: Utils.getFrontendBaseURL()})

if (Utils.isDesktop()) {
if (windowAny.desktopAPI?.onBrowserHistoryPush) {
windowAny.desktopAPI.onBrowserHistoryPush((pathName) => handleBrowserHistoryPush(pathName, history))
} else {
window.addEventListener('message', (event: MessageEvent) => handleBrowserHistoryMessage(event, history))
}
}

return {
...history,
push: (path: string, state?: unknown) => {
if (Utils.isDesktop()) {
doBrowserHistoryPush(`${boardsRouteBase}${path}`)
} else {
history.push(path, state as Record<string, never>)
}
},
}
}
55 changes: 2 additions & 53 deletions webapp/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import React, {useEffect} from 'react'
import {createIntl, createIntlCache} from 'react-intl'
import {Store, Action} from 'redux'
import {Provider as ReduxProvider} from 'react-redux'
import {createBrowserHistory, History} from 'history'
import {History} from 'history'
import {GlobalState} from '@mattermost/types/store'
import {selectTeam} from 'mattermost-redux/actions/teams'

Expand All @@ -21,6 +21,7 @@ import WithWebSockets from './components/withWebSockets'
import {setChannel} from './store/channels'
import {initialLoad} from './store/initialLoad'
import {Utils} from './utils'
import {customHistory} from './desktopHistory'
import './styles/focalboard-variables.scss'
import './styles/main.scss'
import './styles/labels.scss'
Expand Down Expand Up @@ -68,58 +69,6 @@ type Props = {
webSocketClient: MMWebSocketClient
}

const doBrowserHistoryPush = (path: string) => {
if (windowAny.desktopAPI?.sendBrowserHistoryPush) {
windowAny.desktopAPI.sendBrowserHistoryPush(path)
} else {
window.postMessage(
{
type: 'browser-history-push',
message: { path },
},
window.location.origin,
)
}
}

const handleBrowserHistoryPush = (pathName: string, history: ReturnType<typeof createBrowserHistory>) => {
if (!pathName || !pathName.startsWith('/boards')) {
return
}

Utils.log(`Navigating Boards to ${pathName}`)
history.replace(pathName.replace('/boards', ''))
}

function customHistory() {
const history = createBrowserHistory({ basename: Utils.getFrontendBaseURL() })

if (Utils.isDesktop()) {
if (windowAny.desktopAPI?.onBrowserHistoryPush) {
windowAny.desktopAPI.onBrowserHistoryPush((pathName) => handleBrowserHistoryPush(pathName, history))
} else {
window.addEventListener('message', (event: MessageEvent) => {
if (event.origin !== windowAny.location.origin) {
return
}

handleBrowserHistoryPush(event.data.message?.pathName, history)
})
}
}

return {
...history,
push: (path: string, state?: unknown) => {
if (Utils.isDesktop()) {
doBrowserHistoryPush(`${windowAny.frontendBaseURL}${path}`)
} else {
history.push(path, state as Record<string, never>)
}
},
}
}

let browserHistory: History<unknown>

const MainApp = (props: Props) => {
Expand Down
Loading