diff --git a/interface.d.ts b/interface.d.ts index bafaa97b6..443918f28 100644 --- a/interface.d.ts +++ b/interface.d.ts @@ -93,5 +93,6 @@ export interface IElectronAPI { declare global { interface Window { electron: IElectronAPI + openExternalLink: (e: React.MouseEvent) => void } } diff --git a/src/components/ToastUpdate.test.tsx b/src/components/ToastUpdate.test.tsx index 1c5cc8c30..8b141e701 100644 --- a/src/components/ToastUpdate.test.tsx +++ b/src/components/ToastUpdate.test.tsx @@ -150,4 +150,31 @@ describe('ToastUpdate tests', () => { expect(restartButton).toBeEnabled() expect(dismissButton).toBeEnabled() }) + + test('Happy path: external links render correctly', () => { + const releaseNotesWithBreakingChanges = ` +## Some markdown release notes +- [Zoo](https://zoo.dev/) +` + const onRestart = vi.fn() + const onDismiss = vi.fn() + + render( + + ) + + // Locators and other constants + const zooDev = screen.getByText('Zoo', { + selector: 'a', + }) + + expect(zooDev).toHaveAttribute('href', 'https://zoo.dev/') + expect(zooDev).toHaveAttribute('target', '_blank') + expect(zooDev).toHaveAttribute('onClick') + }) }) diff --git a/src/components/ToastUpdate.tsx b/src/components/ToastUpdate.tsx index 5a862529b..fbedc03fd 100644 --- a/src/components/ToastUpdate.tsx +++ b/src/components/ToastUpdate.tsx @@ -1,8 +1,9 @@ import toast from 'react-hot-toast' import { ActionButton } from './ActionButton' import { openExternalBrowserIfDesktop } from 'lib/openWindow' -import { Marked } from '@ts-stack/markdown' +import { escape, Marked, MarkedOptions, unescape } from '@ts-stack/markdown' import { getReleaseUrl } from 'routes/Settings' +import { SafeRenderer } from 'lib/markdown' export function ToastUpdate({ version, @@ -19,6 +20,14 @@ export function ToastUpdate({ ?.toLocaleLowerCase() .includes('breaking') + const markedOptions: MarkedOptions = { + gfm: true, + breaks: true, + sanitize: true, + unescape, + escape, + } + return (
@@ -58,9 +67,8 @@ export function ToastUpdate({ className="parsed-markdown py-2 px-4 mt-2 border-t border-chalkboard-30 dark:border-chalkboard-60 max-h-60 overflow-y-auto" dangerouslySetInnerHTML={{ __html: Marked.parse(releaseNotes, { - gfm: true, - breaks: true, - sanitize: true, + renderer: new SafeRenderer(markedOptions), + ...markedOptions, }), }} >
diff --git a/src/lib/markdown.ts b/src/lib/markdown.ts new file mode 100644 index 000000000..aecf4df91 --- /dev/null +++ b/src/lib/markdown.ts @@ -0,0 +1,52 @@ +import { MarkedOptions, Renderer, unescape } from '@ts-stack/markdown' +import { openExternalBrowserIfDesktop } from './openWindow' + +/** + * Main goal of this custom renderer is to prevent links from changing the current location + * this is specially important for the desktop app. + */ +export class SafeRenderer extends Renderer { + constructor(options: MarkedOptions) { + super(options) + + // Attach a global function for non-react anchor elements that need safe navigation + window.openExternalLink = (e: React.MouseEvent) => { + openExternalBrowserIfDesktop()(e) + } + } + + // Extended from https://github.com/ts-stack/markdown/blob/c5c1925c1153ca2fe9051c356ef0ddc60b3e1d6a/packages/markdown/src/renderer.ts#L116 + link(href: string, title: string, text: string): string { + if (this.options.sanitize) { + let prot: string + + try { + prot = decodeURIComponent(unescape(href)) + .replace(/[^\w:]/g, '') + .toLowerCase() + } catch (e) { + return text + } + + if ( + // eslint-disable-next-line no-script-url + prot.indexOf('javascript:') === 0 || + prot.indexOf('vbscript:') === 0 || + prot.indexOf('data:') === 0 + ) { + return text + } + } + + let out = + '' + + return out + } +}