diff --git a/electron/shared-with-frontend/is-external-url-allowed.ts b/electron/shared-with-frontend/is-external-url-allowed.ts index 76c42b7df2..6980a3debe 100644 --- a/electron/shared-with-frontend/is-external-url-allowed.ts +++ b/electron/shared-with-frontend/is-external-url-allowed.ts @@ -31,6 +31,7 @@ export const ALLOWED_EXTERNAL_URL_SCHEMES = [ 'vscode-insiders:', 'zotero:', 'logseq:', + 'webexteams:', ]; const LOCAL_FILE_URL_PREFIX = 'file:///'; diff --git a/src/app/ui/is-external-url-allowed.spec.ts b/src/app/ui/is-external-url-allowed.spec.ts index cdf933b6fa..e99aa8afc7 100644 --- a/src/app/ui/is-external-url-allowed.spec.ts +++ b/src/app/ui/is-external-url-allowed.spec.ts @@ -22,6 +22,7 @@ describe('isExternalUrlSchemeAllowed', () => { 'vscode-insiders://file/home/user/x.ts', 'zotero://select/items/0_ABCD1234', 'logseq://graph/Notes?page=Today', + 'webexteams://im?space=ff135070-68f8-11f1-9229-c7e6cca7a7cd&message=f4f13440-6b50-11f1-8868-03e71232fa87', ]; allowed.forEach((url) => { it(`allows "${url}"`, () => { @@ -42,6 +43,7 @@ describe('isExternalUrlSchemeAllowed', () => { 'vscode-insiders:', 'zotero:', 'logseq:', + 'webexteams:', ]); }); }); diff --git a/src/app/ui/marked-options-factory.spec.ts b/src/app/ui/marked-options-factory.spec.ts index a56ecaebb8..d9553d3c65 100644 --- a/src/app/ui/marked-options-factory.spec.ts +++ b/src/app/ui/marked-options-factory.spec.ts @@ -390,13 +390,14 @@ describe('markedOptionsFactory', () => { // #8429: app deep-links must stay clickable 'obsidian://open?vault=Notes', 'vscode://file/home/user/x.ts', + 'webexteams://im?space=ff135070-68f8-11f1-9229-c7e6cca7a7cd&message=f4f13440-6b50-11f1-8868-03e71232fa87', ].forEach((href) => { const result = linkRenderer({ href, title: '', tokens: [{ type: 'text', raw: 'L', text: 'L' }], } as any); - expect(result).toContain(`href="${href}"`); + expect(result).toContain(`href="${escapeHtmlAttr(href)}"`); }); }); @@ -414,6 +415,17 @@ describe('markedOptionsFactory', () => { ); }); }); + + it('auto-links raw webexteams:// URIs with their query string intact', () => { + const uri = + 'webexteams://im?space=ff135070-68f8-11f1-9229-c7e6cca7a7cd&message=f4f13440-6b50-11f1-8868-03e71232fa87'; + const result = parseWithFactory(`Open ${uri}`); + + expect(result).toContain(`href="${uri.replace(/&/g, '&')}"`); + expect(result).toContain( + 'webexteams://im?space=ff135070-68f8-11f1-9229-c7e6cca7a7cd&message=f4f13440-6b50-11f1-8868-03e71232fa87', + ); + }); }); describe('image renderer', () => { diff --git a/src/app/ui/marked-options-factory.ts b/src/app/ui/marked-options-factory.ts index 7bd318b270..66eb8dc9cb 100644 --- a/src/app/ui/marked-options-factory.ts +++ b/src/app/ui/marked-options-factory.ts @@ -1,5 +1,10 @@ import { MarkedOptions, MarkedRenderer } from 'ngx-markdown'; -import { Hooks, Token } from 'marked'; +import { + Hooks, + type Token, + type TokenizerExtensionFunction, + type TokenizerStartFunction, +} from 'marked'; import { isExternalUrlSchemeAllowed, isPathSafeToOpen, @@ -62,6 +67,48 @@ export const preprocessMarkdown = (markdown: string): string => { ); }; +const WEBEX_TEAMS_URI_PREFIX = 'webexteams://'; +const WEBEX_TEAMS_URI_RE = /^webexteams:\/\/\S{1,2000}/i; +const WEBEX_TRAILING_PUNCT_RE = /[.,;!?]+$/; + +const stripWebexTeamsUriTrailing = (raw: string): string => { + const uri = raw.replace(WEBEX_TRAILING_PUNCT_RE, ''); + let opens = 0; + let closes = 0; + for (let i = 0; i < uri.length; i++) { + const c = uri.charCodeAt(i); + if (c === 40) opens++; + else if (c === 41) closes++; + } + let end = uri.length; + while (end > 0 && uri.charCodeAt(end - 1) === 41 && closes > opens) { + end--; + closes--; + } + return end < uri.length ? uri.substring(0, end) : uri; +}; + +const startWebexTeamsAutoLink: TokenizerStartFunction = (src: string): number | void => { + const index = src.toLowerCase().indexOf(WEBEX_TEAMS_URI_PREFIX); + return index >= 0 ? index : undefined; +}; + +const tokenizeWebexTeamsAutoLink: TokenizerExtensionFunction = (src: string) => { + const match = WEBEX_TEAMS_URI_RE.exec(src); + if (!match) { + return undefined; + } + + const href = stripWebexTeamsUriTrailing(match[0]); + return { + type: 'link', + raw: href, + href, + text: href, + tokens: [{ type: 'text', raw: href, text: href }], + }; +}; + export const markedOptionsFactory = (): MarkedOptions => { const renderer = new MarkedRenderer(); @@ -196,6 +243,12 @@ export const markedOptionsFactory = (): MarkedOptions => { gfm: true, breaks: true, pedantic: false, + extensions: { + renderers: {}, + childTokens: {}, + inline: [tokenizeWebexTeamsAutoLink], + startInline: [startWebexTeamsAutoLink], + }, }; // Add preprocessing hook to handle image sizing syntax diff --git a/src/app/ui/pipes/render-links.pipe.spec.ts b/src/app/ui/pipes/render-links.pipe.spec.ts index b6a6b816e6..7b9b6d3b4c 100644 --- a/src/app/ui/pipes/render-links.pipe.spec.ts +++ b/src/app/ui/pipes/render-links.pipe.spec.ts @@ -240,6 +240,18 @@ describe('RenderLinksPipe', () => { expect((result.match(/ { + const uri = + 'webexteams://im?space=ff135070-68f8-11f1-9229-c7e6cca7a7cd&message=f4f13440-6b50-11f1-8868-03e71232fa87'; + const result = html(pipe.transform(`Open ${uri}`)); + + expect(result).toContain(`href="${uri.replace(/&/g, '&')}"`); + expect(result).toContain( + '>webexteams://im?space=ff135070-68f8-11f1-9229-c7e6cca7a7cd&message=f4f13440-6b50-11f1-8868-03e71232fa87', + ); + expect(result).not.toContain('http://webexteams'); + }); + it('should reject empty URLs in markdown links', () => { const result = html(pipe.transform('[text]()')); expect(result).toContain('text'); diff --git a/src/app/ui/pipes/render-links.pipe.ts b/src/app/ui/pipes/render-links.pipe.ts index 303f76b872..5e8289821f 100644 --- a/src/app/ui/pipes/render-links.pipe.ts +++ b/src/app/ui/pipes/render-links.pipe.ts @@ -13,11 +13,12 @@ export const hasLinkHints = (text: string): boolean => text.includes(LINK_HINT_WWW) || text.includes(LINK_HINT_MARKDOWN); -// URL regex matching URLs with protocol (http, https, file) or www prefix. +// URL regex matching URLs with protocol (http, https, file, webexteams) or www prefix. // ftp://, ssh://, blob:, etc. are intentionally excluded — they are either // non-browsable or handled by _isUrlSchemeSafe's denylist for markdown links. // Limit URL length to 2000 chars to prevent ReDoS attacks. -const URL_REGEX = /(?:(?:https?|file):\/\/\S{1,2000}(?=\s|$)|www\.\S{1,2000}(?=\s|$))/gi; +const URL_REGEX = + /(?:(?:https?|file|webexteams):\/\/\S{1,2000}(?=\s|$)|www\.\S{1,2000}(?=\s|$))/gi; // Markdown link regex: [title](url) // The URL group allows one level of balanced parentheses so that links like