mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-17 16:37:43 +00:00
Auto-link raw webexteams URIs (#8560)
* feat: auto-link webexteams URIs * test: expect escaped webexteams URIs --------- Co-authored-by: Aamer Akhter <aamer_akhter@users.noreply.bitbucket.org>
This commit is contained in:
parent
8eda115468
commit
677d4aa57f
6 changed files with 85 additions and 4 deletions
|
|
@ -31,6 +31,7 @@ export const ALLOWED_EXTERNAL_URL_SCHEMES = [
|
|||
'vscode-insiders:',
|
||||
'zotero:',
|
||||
'logseq:',
|
||||
'webexteams:',
|
||||
];
|
||||
|
||||
const LOCAL_FILE_URL_PREFIX = 'file:///';
|
||||
|
|
|
|||
|
|
@ -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:',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -240,6 +240,18 @@ describe('RenderLinksPipe', () => {
|
|||
expect((result.match(/<a /g) || []).length).toBe(2);
|
||||
});
|
||||
|
||||
it('should auto-link plain webexteams:// URIs and preserve query params', () => {
|
||||
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</a>',
|
||||
);
|
||||
expect(result).not.toContain('http://webexteams');
|
||||
});
|
||||
|
||||
it('should reject empty URLs in markdown links', () => {
|
||||
const result = html(pipe.transform('[text]()'));
|
||||
expect(result).toContain('text');
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue