diff --git a/web/src/components/MarkdownRenderer.tsx b/web/src/components/MarkdownRenderer.tsx index 7ac71bb2..280ff217 100644 --- a/web/src/components/MarkdownRenderer.tsx +++ b/web/src/components/MarkdownRenderer.tsx @@ -7,6 +7,8 @@ import { MARKDOWN_COMPONENTS_BY_LANGUAGE, MARKDOWN_CLASSNAME, defaultComponents, + denyOnlyTransform, + UriConfirmProvider, } from '@/components/assistant-ui/markdown-text' import { cn } from '@/lib/utils' @@ -22,15 +24,18 @@ function MarkdownContent(props: MarkdownRendererProps) { : defaultComponents return ( - - - + + + + + ) } diff --git a/web/src/components/UriConfirmDialog.test.tsx b/web/src/components/UriConfirmDialog.test.tsx new file mode 100644 index 00000000..28c835c9 --- /dev/null +++ b/web/src/components/UriConfirmDialog.test.tsx @@ -0,0 +1,112 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' +import { render, screen, fireEvent, cleanup } from '@testing-library/react' +import { I18nProvider } from '@/lib/i18n-context' +import { UriConfirmDialog } from '@/components/UriConfirmDialog' + +function renderDialog(props: Partial> = {}) { + const defaults = { + open: true, + url: 'obsidian://open?vault=MyVault&file=Notes%2Ftest', + scheme: 'obsidian', + onCancel: vi.fn(), + onOpen: vi.fn(), + onAlwaysAllow: vi.fn(), + } + const merged = { ...defaults, ...props } + return render( + + + + ) +} + +beforeEach(() => { + vi.clearAllMocks() +}) + +afterEach(() => { + cleanup() +}) + +describe('UriConfirmDialog', () => { + it('renders dialog title when open', () => { + renderDialog() + expect(screen.getByText('Open this link?')).toBeInTheDocument() + }) + + it('displays the scheme prefix in the URI display', () => { + renderDialog({ url: 'obsidian://open?vault=MyVault&file=Notes%2Ftest', scheme: 'obsidian' }) + // The URI is split: scheme prefix + remainder in sibling spans. + expect(screen.getByText('obsidian:')).toBeInTheDocument() + }) + + it('displays the URL remainder after the scheme prefix', () => { + renderDialog({ url: 'obsidian://open?vault=MyVault&file=Notes%2Ftest', scheme: 'obsidian' }) + expect(screen.getByText('//open?vault=MyVault&file=Notes%2Ftest')).toBeInTheDocument() + }) + + it('emphasizes the scheme in the URI display', () => { + renderDialog({ scheme: 'obsidian' }) + // The scheme label should appear visually prominent (e.g. as a separate span) + expect(screen.getByText('obsidian:')).toBeInTheDocument() + }) + + it('renders Cancel, Open, and Always allow buttons', () => { + renderDialog() + expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument() + expect(screen.getByRole('button', { name: /^open$/i })).toBeInTheDocument() + expect(screen.getByRole('button', { name: /always allow/i })).toBeInTheDocument() + }) + + it('calls onCancel when Cancel button is clicked', () => { + const onCancel = vi.fn() + renderDialog({ onCancel }) + fireEvent.click(screen.getByRole('button', { name: /cancel/i })) + expect(onCancel).toHaveBeenCalledOnce() + }) + + it('calls onOpen when Open button is clicked', () => { + const onOpen = vi.fn() + renderDialog({ onOpen }) + fireEvent.click(screen.getByRole('button', { name: /^open$/i })) + expect(onOpen).toHaveBeenCalledOnce() + }) + + it('calls onAlwaysAllow with the scheme when Always allow is clicked', () => { + const onAlwaysAllow = vi.fn() + renderDialog({ scheme: 'obsidian', onAlwaysAllow }) + fireEvent.click(screen.getByRole('button', { name: /always allow/i })) + expect(onAlwaysAllow).toHaveBeenCalledWith('obsidian') + }) + + it('does not render dialog content when open is false', () => { + renderDialog({ open: false }) + expect(screen.queryByText('Open this link?')).not.toBeInTheDocument() + }) + + it('renders without error in open and closed states (onOpenChange wiring)', () => { + // Verifies the Dialog onOpenChange prop is wired so that open→closed calls onCancel. + // Direct Esc simulation is unreliable in jsdom; we confirm both states render cleanly. + const onCancel = vi.fn() + const { rerender } = renderDialog({ onCancel, open: true }) + expect(screen.getByText('Open this link?')).toBeInTheDocument() + rerender( + + + + ) + expect(screen.queryByText('Open this link?')).not.toBeInTheDocument() + }) + + it('includes the scheme name in the Always allow button label', () => { + renderDialog({ scheme: 'vscode' }) + expect(screen.getByRole('button', { name: /always allow vscode/i })).toBeInTheDocument() + }) +}) diff --git a/web/src/components/UriConfirmDialog.tsx b/web/src/components/UriConfirmDialog.tsx new file mode 100644 index 00000000..1457d16b --- /dev/null +++ b/web/src/components/UriConfirmDialog.tsx @@ -0,0 +1,79 @@ +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, +} from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' +import { useTranslation } from '@/lib/use-translation' + +export type UriConfirmDialogProps = { + /** Whether the dialog is visible. */ + open: boolean + /** The full URL being navigated to. */ + url: string + /** The scheme portion of the URL (without the colon), e.g. "obsidian". */ + scheme: string + /** Called when the user dismisses the dialog without navigating. */ + onCancel: () => void + /** Called when the user chooses to open the link once. */ + onOpen: () => void + /** Called when the user chooses to always allow this scheme. */ + onAlwaysAllow: (scheme: string) => void +} + +/** + * Confirmation dialog shown before navigating to a non-IANA URI scheme. + * Follows the RenameSessionDialog pattern (Radix Dialog + Button + i18n). + */ +export function UriConfirmDialog(props: UriConfirmDialogProps) { + const { open, url, scheme, onCancel, onOpen, onAlwaysAllow } = props + const { t } = useTranslation() + + // Split URL into scheme prefix and the rest for visual emphasis. + const schemePrefix = `${scheme}:` + const urlRemainder = url.startsWith(schemePrefix) ? url.slice(schemePrefix.length) : url + + return ( + !isOpen && onCancel()}> + + + {t('dialog.uri.title')} + + + {t('dialog.uri.description')} + + + {/* URI display with scheme emphasis */} + + {schemePrefix} + {urlRemainder} + + + + + {t('button.cancel')} + + + {t('dialog.uri.open')} + + onAlwaysAllow(scheme)} + > + {t('dialog.uri.alwaysAllow', { scheme })} + + + + + ) +} diff --git a/web/src/components/assistant-ui/markdown-a.test.tsx b/web/src/components/assistant-ui/markdown-a.test.tsx new file mode 100644 index 00000000..8ab22168 --- /dev/null +++ b/web/src/components/assistant-ui/markdown-a.test.tsx @@ -0,0 +1,335 @@ +/** + * Tests for the custom anchor component and the inlined URL policy helpers + * in markdown-text.tsx. + * + * Covers: + * - classifyScheme: IANA / deny / custom, 6-axis security bypass + * - denyOnlyTransform: deny → "", IANA/custom → pass-through, relative paths + * - useAllowedSchemes (inlined): localStorage roundtrip, cross-tab storage event, tamper guard + * - component click behaviour: deny, IANA, custom (dialog opened via context) + * - intra-tab cross-provider sync via module-level schemeListeners emitter + */ +import { describe, expect, it, vi, beforeEach } from 'vitest' +import { render, screen, fireEvent, cleanup, act, waitFor } from '@testing-library/react' +import React from 'react' +import { defaultComponents, classifyScheme, denyOnlyTransform, UriConfirmProvider } from '@/components/assistant-ui/markdown-text' +import { I18nProvider } from '@/lib/i18n-context' + +// defaultComponents.a is the memoized A component. +const AnchorComponent = (defaultComponents as Record).a as React.ComponentType< + React.ComponentPropsWithoutRef<'a'> +> + +// Helper: wrap A in UriConfirmProvider so useContext(UriConfirmContext) is non-null. +// Previously had a localHook fallback for bare renders, but that fallback +// added a storage listener per link (N links → N+1 listeners). The fallback is +// removed; tests must provide the context instead. +function renderA(props: React.ComponentPropsWithoutRef<'a'>) { + return render( + + + + + + ) +} + +const STORAGE_KEY = 'hapi-allowed-schemes' + +beforeEach(() => { + localStorage.clear() + cleanup() + vi.clearAllMocks() +}) + +// ── classifyScheme ──────────────────────────────────────────────────────────── + +describe('classifyScheme — IANA schemes', () => { + it.each(['https://example.com', 'http://example.com', 'mailto:user@x.com', 'irc://irc.libera.chat', 'ircs://irc.libera.chat', 'xmpp:user@x.com'])( + 'classifies %s as iana', + (url) => expect(classifyScheme(url)).toBe('iana') + ) + it('classifies HTTPS: (uppercase) as iana (case-insensitive)', () => { + expect(classifyScheme('HTTPS://example.com')).toBe('iana') + }) +}) + +describe('classifyScheme — deny schemes', () => { + it.each(['javascript:alert(1)', 'data:text/html,xss', 'vbscript:msgbox(1)', 'file:///tmp/test.txt'])( + 'classifies %s as deny', + (url) => expect(classifyScheme(url)).toBe('deny') + ) +}) + +describe('classifyScheme — custom schemes', () => { + it.each(['obsidian://open?vault=V&file=F', 'vscode://file/path', 'slack://channel?team=T123'])( + 'classifies %s as custom', + (url) => expect(classifyScheme(url)).toBe('custom') + ) +}) + +describe('classifyScheme — security bypass axes', () => { + // (a) case bypass + it.each(['JavaScript:alert(1)', 'JAVASCRIPT:alert(1)'])('blocks %s (case) as deny', (url) => + expect(classifyScheme(url)).toBe('deny') + ) + // (b) whitespace prefix on entire URL + it.each(['\tjavascript:alert(1)', '\njavascript:alert(1)', ' javascript:alert(1)'])('blocks %s (whitespace prefix) as deny', (url) => + expect(classifyScheme(url)).toBe('deny') + ) + // (c) percent-encoding + it('%6Aavascript: (encoded j) → deny', () => expect(classifyScheme('%6Aavascript:alert(1)')).toBe('deny')) + it('jav%61script: (encoded a) → deny', () => expect(classifyScheme('jav%61script:alert(1)')).toBe('deny')) + // (d) double-encoding — 2-pass decode unwraps javascript%253A → javascript%3A → javascript: + // With 2-pass decode, the second pass resolves %3A → literal colon, so the scheme + // "javascript" is extracted and hits DENY_SCHEMES → 'deny' via scheme-match. + it('javascript%253A (double-encoded colon) → deny', () => expect(classifyScheme('javascript%253Aalert(1)')).toBe('deny')) + // `javascript%3Aalert(1)` — single-encoded colon. decodeURIComponent yields + // `javascript:alert(1)` with a literal colon, so classifyScheme extracts scheme + // "javascript" → hits DENY_SCHEMES → 'deny'. This is the real scheme-match path. + it('javascript%3A (single-encoded colon) → deny via scheme-match', () => expect(classifyScheme('javascript%3Aalert(1)')).toBe('deny')) + // (e) control characters spliced into scheme name + // Browsers strip \n, \t, \r from URL schemes during navigation; our normalizer + // must do the same before comparing against the deny list. + it('java\\nscript: (newline in scheme) → deny', () => expect(classifyScheme('java\nscript:alert(1)')).toBe('deny')) + it('java\\tscript: (tab in scheme) → deny', () => expect(classifyScheme('java\tscript:alert(1)')).toBe('deny')) + it('java\\rscript: (carriage return in scheme) → deny', () => expect(classifyScheme('java\rscript:alert(1)')).toBe('deny')) + it('java script: (space in scheme) → deny', () => expect(classifyScheme('java script:alert(1)')).toBe('deny')) + // percent-encoded control chars inside the scheme — decoded by pass 1 then stripped + it('java%0Ascript: (percent-encoded newline in scheme) → deny', () => expect(classifyScheme('java%0Ascript:alert(1)')).toBe('deny')) + // leading whitespace on the URL itself (already covered by trimStart, added for completeness) + it('\\tjavascript: (leading tab on URL) → deny', () => expect(classifyScheme('\tjavascript:alert(1)')).toBe('deny')) + // case sanity (also covered above but keep explicit) + it('JAVASCRIPT: → deny', () => expect(classifyScheme('JAVASCRIPT:alert(1)')).toBe('deny')) + it('JaVaScRipT: → deny', () => expect(classifyScheme('JaVaScRipT:')).toBe('deny')) + // edge + it('empty string → deny', () => expect(classifyScheme('')).toBe('deny')) + it('no-colon string → deny', () => expect(classifyScheme('not-a-url')).toBe('deny')) +}) + +// ── denyOnlyTransform ───────────────────────────────────────────────────────── + +describe('denyOnlyTransform', () => { + it.each(['javascript:alert(1)', 'data:text/html,xss', 'vbscript:x', 'file:///tmp/f', 'JavaScript:alert(1)', 'jav%61script:alert(1)', '%6Aavascript:alert(1)'])( + 'strips %s → ""', + (url) => expect(denyOnlyTransform(url)).toBe('') + ) + it.each(['https://example.com', 'http://example.com', 'mailto:a@b.com'])( + 'passes %s through unchanged', + (url) => expect(denyOnlyTransform(url)).toBe(url) + ) + it.each(['obsidian://open?vault=V', 'vscode://file/path', 'slack://channel'])( + 'passes custom scheme %s through unchanged', + (url) => expect(denyOnlyTransform(url)).toBe(url) + ) + it('passes relative path through', () => { + expect(denyOnlyTransform('/relative/path')).toBe('/relative/path') + }) +}) + +// ── useAllowedSchemes (inlined hook, tested via A component) ────────────────── + +describe('localStorage roundtrip via A component', () => { + it('renders href="#" for unallowed custom scheme', () => { + renderA({ href: 'obsidian://open?vault=V&file=F', children: 'note' }) + expect(document.querySelector('a')!.getAttribute('href')).toBe('#') + }) + + it('renders real href for pre-seeded allowed custom scheme', () => { + localStorage.setItem(STORAGE_KEY, JSON.stringify(['obsidian'])) + renderA({ href: 'obsidian://open?vault=V&file=F', children: 'note' }) + expect(document.querySelector('a')!.getAttribute('href')).toBe('obsidian://open?vault=V&file=F') + }) + + it('isAllowed returns false for deny scheme even if tampered into localStorage', () => { + // Tamper: put javascript into allowed list + localStorage.setItem(STORAGE_KEY, JSON.stringify(['javascript'])) + // The A component should still not treat javascript as allowed (it classifies to 'deny') + renderA({ href: 'javascript:alert(1)', children: 'evil' }) + // href="" comes from denyOnlyTransform; onclick should preventDefault + const link = document.querySelector('a')! + const clickEvent = new MouseEvent('click', { bubbles: true, cancelable: true }) + const preventSpy = vi.spyOn(clickEvent, 'preventDefault') + link.dispatchEvent(clickEvent) + expect(preventSpy).toHaveBeenCalled() + }) +}) + +describe('cross-tab sync via storage event', () => { + it('updates after storage event fires (simulated other-tab write)', () => { + // Start with empty storage, render an unallowed custom link + renderA({ href: 'obsidian://open', children: 'note' }) + expect(document.querySelector('a')!.getAttribute('href')).toBe('#') + + // Simulate another tab writing the allowed schemes + act(() => { + localStorage.setItem(STORAGE_KEY, JSON.stringify(['obsidian'])) + window.dispatchEvent(new StorageEvent('storage', { + key: STORAGE_KEY, + newValue: JSON.stringify(['obsidian']), + storageArea: localStorage, + })) + }) + + // After storage event the hook re-reads; re-render the component + cleanup() + renderA({ href: 'obsidian://open', children: 'note' }) + expect(document.querySelector('a')!.getAttribute('href')).toBe('obsidian://open') + }) +}) + +// ── component — click handler ──────────────────────────────────────────── + +describe('markdown component — click handler', () => { + it('prevents default when href is empty string (deny-scheme link)', () => { + renderA({ href: '', children: 'deny' }) + const clickEvent = new MouseEvent('click', { bubbles: true, cancelable: true }) + const preventSpy = vi.spyOn(clickEvent, 'preventDefault') + document.querySelector('a')!.dispatchEvent(clickEvent) + expect(preventSpy).toHaveBeenCalled() + }) + + it('prevents default when href is undefined', () => { + renderA({ children: 'no href' }) + const clickEvent = new MouseEvent('click', { bubbles: true, cancelable: true }) + const preventSpy = vi.spyOn(clickEvent, 'preventDefault') + document.querySelector('a')!.dispatchEvent(clickEvent) + expect(preventSpy).toHaveBeenCalled() + }) + + it('renders href="#" for an unallowed custom scheme (no middle-click bypass)', () => { + renderA({ href: 'obsidian://open?vault=V&file=F', children: 'note' }) + expect(document.querySelector('a')!.getAttribute('href')).toBe('#') + }) + + it('renders the real href for an IANA scheme (https)', () => { + renderA({ href: 'https://example.com', children: 'link' }) + expect(document.querySelector('a')!.getAttribute('href')).toBe('https://example.com') + }) + + it('does not navigate for a deny scheme (href="")', () => { + const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null) + renderA({ href: '', children: 'evil' }) + fireEvent.click(document.querySelector('a')!) + expect(openSpy).not.toHaveBeenCalled() + openSpy.mockRestore() + }) +}) + +// ── relative / no-scheme hrefs — regression guard ──────────────────────────── +// +// Finding 2: denyOnlyTransform passes relative hrefs through unchanged (no colon +// → not a scheme URL), but the onClick handler called classifyScheme(href) +// which returned 'deny' for inputs with no valid scheme → preventDefault was +// called → relative/internal links were silently blocked. +// +// Fix: must detect hrefs that have no scheme and treat them as 'iana' so the +// browser/router can navigate normally. + +describe('markdown component — relative / no-scheme hrefs navigate normally', () => { + // Each of these hrefs has no URL scheme. Clicks must NOT be prevented. + // We verify by checking that preventDefault is NOT called on the click event. + + function clickAndCheckNotPrevented(href: string) { + renderA({ href, children: 'link' }) + const clickEvent = new MouseEvent('click', { bubbles: true, cancelable: true }) + const preventSpy = vi.spyOn(clickEvent, 'preventDefault') + document.querySelector('a')!.dispatchEvent(clickEvent) + expect(preventSpy).not.toHaveBeenCalled() + cleanup() + } + + it('/settings → click not prevented (absolute-path relative link)', () => { + clickAndCheckNotPrevented('/settings') + }) + + it('./foo → click not prevented (relative-path link)', () => { + clickAndCheckNotPrevented('./foo') + }) + + it('#section → click not prevented (hash fragment link)', () => { + clickAndCheckNotPrevented('#section') + }) + + it('?q=1 → click not prevented (query-only link)', () => { + clickAndCheckNotPrevented('?q=1') + }) + + it('/path:colon → click not prevented (path with colon, no scheme)', () => { + // "/" appears before ":" so this is a path, not a scheme. + clickAndCheckNotPrevented('/path:colon') + }) + + it('//example.com → click not prevented (protocol-relative URL, no colon)', () => { + // Protocol-relative URLs have no colon; browsers navigate them as the + // current origin's protocol, same as any other relative href. + clickAndCheckNotPrevented('//example.com/path') + }) + + it('https://example.com → click not prevented (regression: IANA still passes through)', () => { + clickAndCheckNotPrevented('https://example.com') + }) +}) + +// ── intra-tab cross-provider sync (schemeListeners emitter) ────────────────── +// +// P7e.1 added a module-level `schemeListeners: Set` so that +// when two sibling s exist in the same window (e.g. +// MarkdownText + Reasoning in AssistantMessage), clicking "Always allow" in +// one provider's dialog immediately updates the other without waiting for a +// cross-tab storage event (which browsers only fire in OTHER tabs). +// +// This test asserts that path: mount two sibling providers, trigger allow() +// in one via the dialog flow, verify the other's link href updates. + +describe('intra-tab cross-provider sync (schemeListeners emitter)', () => { + it('allowing a scheme in one UriConfirmProvider propagates to a sibling provider', async () => { + localStorage.clear() + + // Suppress window.open — handleAlwaysAllow calls it after allow() + const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null) + + render( + + + link1 + + + link2 + + + ) + + const links = screen.getAllByRole('link') + const [a1, a2] = links + + // Both links start blocked (href="#") because obsidian is not yet allowed. + expect(a1.getAttribute('href')).toBe('#') + expect(a2.getAttribute('href')).toBe('#') + + // Click the first link → its provider opens the UriConfirmDialog. + await act(async () => { + fireEvent.click(a1) + }) + + // The "Always allow obsidian:" button is rendered by UriConfirmDialog + // via Radix Dialog portal into document.body. + const alwaysBtn = await waitFor(() => + screen.getByRole('button', { name: /always allow obsidian/i }) + ) + + await act(async () => { + fireEvent.click(alwaysBtn) + }) + + // After "Always allow", the schemeListeners emitter must have notified + // the sibling provider synchronously. Both links should now carry the + // live href (not '#'). + await waitFor(() => { + expect(a1.getAttribute('href')).toBe('obsidian://open?a=1') + expect(a2.getAttribute('href')).toBe('obsidian://open?a=2') + }) + + openSpy.mockRestore() + }) +}) diff --git a/web/src/components/assistant-ui/markdown-text.test.ts b/web/src/components/assistant-ui/markdown-text.test.ts new file mode 100644 index 00000000..43745f3d --- /dev/null +++ b/web/src/components/assistant-ui/markdown-text.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest' +import remarkNonHttpsAutolink from '@/lib/remark-non-https-autolink' +import remarkStripCjkAutolink from '@/lib/remark-strip-cjk-autolink' +import { MARKDOWN_PLUGINS } from '@/components/assistant-ui/markdown-text' + +describe('MARKDOWN_PLUGINS integration', () => { + it('includes remarkNonHttpsAutolink', () => { + expect(MARKDOWN_PLUGINS).toContain(remarkNonHttpsAutolink) + }) + + it('places remarkNonHttpsAutolink BEFORE remarkStripCjkAutolink so CJK strip sees new links', () => { + const idxAutolink = MARKDOWN_PLUGINS.indexOf(remarkNonHttpsAutolink) + const idxCjk = MARKDOWN_PLUGINS.indexOf(remarkStripCjkAutolink) + expect(idxAutolink).toBeGreaterThan(0) // not first (remarkGfm is first) + expect(idxAutolink).toBeLessThan(idxCjk) // autolink before CJK strip + }) +}) diff --git a/web/src/components/assistant-ui/markdown-text.tsx b/web/src/components/assistant-ui/markdown-text.tsx index 308052be..925686cb 100644 --- a/web/src/components/assistant-ui/markdown-text.tsx +++ b/web/src/components/assistant-ui/markdown-text.tsx @@ -1,6 +1,7 @@ import '@assistant-ui/react-markdown/styles/dot.css' import type { ComponentPropsWithoutRef, MouseEvent } from 'react' +import { useState, useCallback, useEffect, useMemo, createContext, useContext, type ReactNode } from 'react' import { MarkdownTextPrimitive, unstable_memoizeMarkdownComponents as memoizeMarkdownComponents, @@ -13,6 +14,7 @@ import rehypeKatex from 'rehype-katex' import remarkDisableIndentedCode from '@/lib/remark-disable-indented-code' import { useNavigate } from '@tanstack/react-router' import remarkStripCjkAutolink from '@/lib/remark-strip-cjk-autolink' +import remarkNonHttpsAutolink from '@/lib/remark-non-https-autolink' import { cn, encodeBase64 } from '@/lib/utils' import { SyntaxHighlighter } from '@/components/assistant-ui/shiki-highlighter' import { MermaidDiagram } from '@/components/assistant-ui/mermaid-diagram' @@ -20,10 +22,26 @@ import { useCopyToClipboard } from '@/hooks/useCopyToClipboard' import { CopyIcon, CheckIcon } from '@/components/icons' import { useOptionalHappyChatContext } from '@/components/AssistantChat/context' import { decodeFilePathHref, remarkFilePathLinks } from '@/lib/remark-file-path-links' +import { UriConfirmDialog } from '@/components/UriConfirmDialog' import type { MarkdownTextPrimitiveProps } from '@assistant-ui/react-markdown' -export const MARKDOWN_PLUGINS = [remarkGfm, remarkStripCjkAutolink, remarkMath, remarkDisableIndentedCode, remarkFilePathLinks] satisfies NonNullable +// ── Plugin array ──────────────────────────────────────────────────────────── +// Order: remarkGfm → remarkNonHttpsAutolink → remarkStripCjkAutolink → remarkMath → remarkDisableIndentedCode → remarkFilePathLinks +// remarkNonHttpsAutolink must run BEFORE remarkStripCjkAutolink so that the +// CJK strip plugin sees the new link nodes and can trim trailing CJK punctuation +// from them. Both must come before remarkMath (to avoid treating TeX as URI). +// remarkFilePathLinks runs last to convert file paths → links after all other +// transforms have settled. +export const MARKDOWN_PLUGINS = [ + remarkGfm, + remarkNonHttpsAutolink, + remarkStripCjkAutolink, + remarkMath, + remarkDisableIndentedCode, + remarkFilePathLinks, // upstream — file path → link conversion, runs last +] satisfies NonNullable + export const MARKDOWN_REHYPE_PLUGINS = [rehypeKatex] satisfies NonNullable export const MARKDOWN_CLASSNAME = 'aui-md happy-chat-text min-w-0 max-w-full break-words text-[var(--app-fg)]' export const MARKDOWN_COMPONENTS_BY_LANGUAGE = { @@ -32,6 +50,292 @@ export const MARKDOWN_COMPONENTS_BY_LANGUAGE = { }, } satisfies NonNullable +// ── URI scheme policy (inlined from url-scheme-policy.ts) ─────────────────── +// +// IANA-registered safe schemes — mirrors react-markdown defaultUrlTransform exactly. +// Source: node_modules/react-markdown/lib/index.js:124 /^(https?|ircs?|mailto|xmpp)$/i +const IANA_SAFE_SCHEMES: ReadonlySet = new Set([ + 'http', + 'https', + 'irc', + 'ircs', + 'mailto', + 'xmpp', +]) + +// Schemes that must always be blocked regardless of user preference. +// Includes file: because Chromium denies navigation from http(s) origins. +const DENY_SCHEMES: ReadonlySet = new Set([ + 'javascript', + 'data', + 'vbscript', + 'file', +]) + +/** + * Extract the normalised scheme from a URL string. + * + * Applies up to two rounds of decodeURIComponent so that double-encoded + * bypass attempts (`javascript%253A` → `javascript%3A` → `javascript:`) + * are unwrapped before the scheme is extracted. + * + * After decoding, ASCII control characters (U+0000–U+001F, U+007F) and + * all whitespace are stripped from the extracted scheme string so that + * browsers' built-in normalization — which silently discards \t, \n, \r + * and space from scheme names during navigation — cannot be used to bypass + * the deny list (e.g. `java\nscript:alert(1)` → scheme `"javascript"`). + * + * Returns null when no valid scheme separator is found. + */ +function normalizedScheme(url: string): string | null { + let value = url.trimStart() + for (let i = 0; i < 2; i++) { + try { + const next = decodeURIComponent(value) + if (next === value) break + value = next + } catch { + break + } + } + const colonIndex = value.indexOf(':') + if (colonIndex <= 0) return null + // Strip ASCII control chars (U+0000–U+001F, DEL U+007F) and all whitespace + // from the scheme name. Browsers strip exactly these characters during + // navigation, so `java\nscript` navigates as `javascript`. + return value.slice(0, colonIndex).replace(/[\x00-\x1F\x7F\s]/g, '').toLowerCase() +} + +/** + * Classify a URL string by its scheme. + * + * Uses normalizedScheme() so that common bypass patterns — control characters + * spliced into the scheme name, tab/space prefix, single- and double-encoded + * scheme characters — are all caught before the deny/IANA comparison. + * + * @returns 'iana' | 'deny' | 'custom' + */ +export function classifyScheme(url: string): 'iana' | 'deny' | 'custom' { + const scheme = normalizedScheme(url) + if (scheme === null) return 'deny' + if (DENY_SCHEMES.has(scheme)) return 'deny' + if (IANA_SAFE_SCHEMES.has(scheme)) return 'iana' + return 'custom' +} + +/** + * Returns true when href contains a URL scheme (i.e. a "scheme:" prefix that + * appears before any path/query/fragment delimiter). + * + * This distinguishes `mailto:foo@bar` (has scheme → true) from purely relative + * hrefs like `/settings`, `./foo`, `#section`, `?q=1`, or paths that contain a + * colon after a path segment like `/path:colon` (→ false, because `/` appears + * before `:`). Protocol-relative URLs (`//host/path`) have no colon and are + * also treated as scheme-less; browsers navigate them as the current origin's + * protocol, same as a normal relative path would do. + * + * Used by to short-circuit classifyScheme for no-scheme hrefs and treat them + * as 'iana' so the browser/router can handle them normally (fixing the regression + * where relative markdown links were silently blocked by the onClick deny guard). + */ +function hasScheme(href: string): boolean { + const colonIdx = href.indexOf(':') + if (colonIdx <= 0) return false + const boundaryIdx = href.search(/[/?#]/) + return boundaryIdx < 0 || colonIdx < boundaryIdx +} + +// ── URL sanitize transform (deny-only) ────────────────────────────────────── +// Passed as urlTransform to MarkdownTextPrimitive. Only deny-listed schemes +// are stripped; every other scheme (IANA + custom) passes through so the +// is preserved for the onClick layer to handle. +// +// Uses classifyScheme as the single source of truth for scheme extraction so +// that percent-encoded bypass patterns (jav%61script:, %6Aavascript:) are +// caught by the same decoding logic used at click time. +// +// Relative paths (no colon, or colon only in path/query) have no scheme and +// are always passed through — they are safe and used for img src etc. +// +// Known limitation (FIX 5, deferred): data:image/png;base64,... used in +// is also stripped because DENY_SCHEMES includes 'data'. However, +// react-markdown's own defaultUrlTransform strips all data: URLs identically, +// so this is not a regression introduced by this PR. +export function denyOnlyTransform(url: string): string { + if (!url) return url + const trimmed = url.trimStart() + const colonIdx = trimmed.indexOf(':') + const slashIdx = trimmed.search(/[/?#]/) + if (colonIdx < 0 || (slashIdx >= 0 && slashIdx < colonIdx)) { + return url + } + return classifyScheme(url) === 'deny' ? '' : url +} + +// ── Allowed-schemes state (inlined from useAllowedSchemes.ts) ─────────────── + +const STORAGE_KEY = 'hapi-allowed-schemes' + +// Module-level subscriber set for intra-tab cross-provider sync (P7e.1). +// +// `window` `storage` events fire only in OTHER tabs/windows, not in the same +// tab that called setItem. When two s exist in the same +// window (e.g. MarkdownText + Reasoning as sibling providers in AssistantMessage), +// clicking "Always allow" in one provider's dialog must immediately update the +// other without waiting for a page reload. +// +// Solution: a module-level Set of subscriber callbacks. writeAllowedToStorage +// calls each subscriber synchronously after writing to localStorage. Each +// useAllowedSchemes instance registers on mount and unregisters on unmount. +// Cross-tab sync continues to use the existing window `storage` event. +type SchemeListener = (schemes: ReadonlySet) => void +const schemeListeners = new Set() + +function readAllowedFromStorage(): Set { + try { + const raw = localStorage.getItem(STORAGE_KEY) + if (!raw) return new Set() + const parsed = JSON.parse(raw) + if (!Array.isArray(parsed)) return new Set() + return new Set( + parsed + .filter((s): s is string => typeof s === 'string') + .filter((s) => !DENY_SCHEMES.has(s)) + ) + } catch { + return new Set() + } +} + +function writeAllowedToStorage(schemes: ReadonlySet): void { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(Array.from(schemes))) + } catch { + // Storage quota exceeded — silently ignore; in-memory set still works. + } + // Notify all same-tab subscribers synchronously so sibling providers + // (e.g. MarkdownText + Reasoning) update in the same React event loop tick. + for (const listener of schemeListeners) { + listener(schemes) + } +} + +function useAllowedSchemes() { + const [allowed, setAllowed] = useState>(() => readAllowedFromStorage()) + + useEffect(() => { + // Cross-tab sync: storage event fires in other tabs/windows. + function handleStorage(e: StorageEvent) { + if (e.key !== STORAGE_KEY) return + setAllowed(readAllowedFromStorage()) + } + window.addEventListener('storage', handleStorage) + + // Intra-tab sync: subscribe to module-level emitter so sibling + // providers in the same window see updates without page reload. + function handleIntraTab(schemes: ReadonlySet) { + setAllowed(new Set(schemes)) + } + schemeListeners.add(handleIntraTab) + + return () => { + window.removeEventListener('storage', handleStorage) + schemeListeners.delete(handleIntraTab) + } + }, []) + + const allow = useCallback((scheme: string) => { + if (DENY_SCHEMES.has(scheme)) return + setAllowed((prev) => { + if (prev.has(scheme)) return prev + const next = new Set(prev) + next.add(scheme) + writeAllowedToStorage(next) + return next + }) + }, []) + + const isAllowed = useCallback( + (scheme: string): boolean => { + if (DENY_SCHEMES.has(scheme)) return false + return allowed.has(scheme) + }, + [allowed] + ) + + return { allowed, allow, isAllowed } +} + +// ── UriConfirmContext — one dialog per markdown root ──────────────────────── + +type DialogState = { + url: string + scheme: string +} | null + +type UriConfirmContextValue = { + openUri: (url: string, scheme: string) => void + /** Shared isAllowed so all tags in this tree re-render on the same state update. */ + isAllowed: (scheme: string) => boolean +} + +const UriConfirmContext = createContext(null) + +/** + * Provider that mounts a single for all links in its + * subtree. Wrap each MarkdownText / MarkdownRenderer / Reasoning surface with + * this so that only one dialog instance exists per markdown root instead of + * one per tag. + * + * The allowed-schemes state lives here so all child components share the + * same React state — when "Always allow" is clicked, every link in the tree + * re-renders in the same React commit (no cross-tab storage event required). + */ +export function UriConfirmProvider({ children }: { children: ReactNode }) { + const [dialog, setDialog] = useState(null) + const { allow, isAllowed } = useAllowedSchemes() + + const openUri = useCallback((url: string, scheme: string) => { + setDialog({ url, scheme }) + }, []) + + const closeDialog = () => setDialog(null) + + const handleOpen = () => { + if (!dialog) return + closeDialog() + window.open(dialog.url, '_blank', 'noopener,noreferrer') + } + + const handleAlwaysAllow = (allowedScheme: string) => { + allow(allowedScheme) + closeDialog() + if (dialog) { + window.open(dialog.url, '_blank', 'noopener,noreferrer') + } + } + + const contextValue = useMemo(() => ({ openUri, isAllowed }), [openUri, isAllowed]) + + return ( + + {children} + {dialog !== null && ( + + )} + + ) +} + +// ── Components ─────────────────────────────────────────────────────────────── + function CodeHeader(props: CodeHeaderProps) { const { copied, copy } = useCopyToClipboard() const language = props.language && props.language !== 'unknown' ? props.language : 'text' @@ -122,8 +426,35 @@ function FilePathAnchor(props: ComponentPropsWithoutRef<'a'> & { filePath: strin ) } +/** + * Anchor component with URI scheme policy enforcement. + * + * - Relative / no-scheme hrefs (/settings, ./foo, #section, ?q=1): passed through + * without interception so the browser or SPA router can navigate normally. + * - IANA safe schemes (https/http/mailto/irc/ircs/xmpp): navigate directly. + * - Deny schemes (javascript/data/vbscript/file): silently block. denyOnlyTransform + * already strips the href to "", so href="" in DOM (belt-and-suspenders onClick + * guard also calls preventDefault). + * - Custom schemes, NOT yet allowed by user: href="#" in DOM (not the live URL) + * so middle-click / drag-to-bar cannot bypass the dialog. Dialog opens on left-click + * via the shared UriConfirmContext (single dialog per markdown root). + * - Custom schemes, already allowed by user: live href in DOM; middle-click works. + * - File-path links (decoded by remarkFilePathLinks): delegated to FilePathAnchor + * which uses useNavigate for SPA routing. + */ function A(props: ComponentPropsWithoutRef<'a'>) { const chat = useOptionalHappyChatContext() + // useContext must be called unconditionally before any early return so that + // the Rules of Hooks are satisfied regardless of whether `filePath` is set. + // isAllowed comes exclusively from the shared context. Every call site + // (MarkdownText, Reasoning, MarkdownRenderer) wraps its surface with + // , so ctx is always present in production. + // + // The previous localHook fallback instantiated useAllowedSchemes() even + // when ctx was present — 20 anchors → 21 storage listeners per mount. + // Removing it requires tests that render directly to wrap with + // (or supply a mock UriConfirmContext.Provider). + const ctx = useContext(UriConfirmContext) const filePath = typeof props.href === 'string' ? decodeFilePathHref(props.href) : null const rel = props.target === '_blank' ? (props.rel ?? 'noreferrer') : props.rel @@ -134,10 +465,61 @@ function A(props: ComponentPropsWithoutRef<'a'>) { return } + const isAllowed = ctx?.isAllowed ?? (() => false) + + const { onClick, href, ...rest } = props + // Relative / no-scheme hrefs (/settings, ./foo, #section, ?q=1) must not be + // classified via classifyScheme — it returns 'deny' for inputs with no valid + // scheme, which previously caused the onClick handler to preventDefault and + // silently break all relative markdown links. Treat them as 'iana' so the + // browser or SPA router can navigate normally. + const isRelative = href ? !hasScheme(href) : false + const classification = href && !isRelative ? classifyScheme(href) : 'iana' + const colonIdx = href ? href.indexOf(':') : -1 + const scheme = colonIdx > 0 && !isRelative ? href!.slice(0, colonIdx).toLowerCase() : '' + const isCustomAllowed = classification === 'custom' && isAllowed(scheme) + + const domHref = + classification === 'iana' || isCustomAllowed + ? href + : classification === 'custom' + ? '#' + : href + + const handleClick = (e: React.MouseEvent) => { + const url = href ?? '' + + if (!url) { + e.preventDefault() + return + } + + if (classification === 'deny') { + e.preventDefault() + return + } + + if (classification === 'iana') { + onClick?.(e) + return + } + + if (isCustomAllowed) { + onClick?.(e) + return + } + + // Unallowed custom scheme: show confirmation dialog via context. + e.preventDefault() + ctx?.openUri(url, scheme) + } + return ( ) @@ -280,12 +662,15 @@ export const defaultComponents = memoizeMarkdownComponents({ export function MarkdownText() { return ( - + + + ) } diff --git a/web/src/components/assistant-ui/reasoning.tsx b/web/src/components/assistant-ui/reasoning.tsx index ac72711f..10c9cc08 100644 --- a/web/src/components/assistant-ui/reasoning.tsx +++ b/web/src/components/assistant-ui/reasoning.tsx @@ -8,6 +8,8 @@ import { MARKDOWN_PLUGINS, MARKDOWN_REHYPE_PLUGINS, defaultComponents, + denyOnlyTransform, + UriConfirmProvider, } from '@/components/assistant-ui/markdown-text' function ChevronIcon(props: { className?: string; open?: boolean }) { @@ -41,13 +43,16 @@ function ShimmerDot() { export const Reasoning: FC = () => { return ( - + + + ) } diff --git a/web/src/lib/locales/en.ts b/web/src/lib/locales/en.ts index 64219608..7a1190ef 100644 --- a/web/src/lib/locales/en.ts +++ b/web/src/lib/locales/en.ts @@ -83,6 +83,10 @@ export default { 'session.action.copy': 'Copy', // Dialogs + 'dialog.uri.title': 'Open this link?', + 'dialog.uri.description': 'This link uses a custom URI scheme. Make sure you trust this link before opening it.', + 'dialog.uri.open': 'Open', + 'dialog.uri.alwaysAllow': 'Always allow {scheme}:', 'dialog.rename.title': 'Rename Session', 'dialog.rename.placeholder': 'Session name', 'dialog.rename.save': 'Save', diff --git a/web/src/lib/locales/zh-CN.ts b/web/src/lib/locales/zh-CN.ts index 92a40980..3f6ca797 100644 --- a/web/src/lib/locales/zh-CN.ts +++ b/web/src/lib/locales/zh-CN.ts @@ -83,6 +83,10 @@ export default { 'session.action.copy': '复制', // Dialogs + 'dialog.uri.title': '打开此链接?', + 'dialog.uri.description': '此链接使用自定义 URI 协议。请确认您信任此链接后再打开。', + 'dialog.uri.open': '打开', + 'dialog.uri.alwaysAllow': '始终允许 {scheme}:', 'dialog.rename.title': '重命名会话', 'dialog.rename.placeholder': '会话名称', 'dialog.rename.save': '保存', diff --git a/web/src/lib/remark-non-https-autolink.test.ts b/web/src/lib/remark-non-https-autolink.test.ts new file mode 100644 index 00000000..d2e5237b --- /dev/null +++ b/web/src/lib/remark-non-https-autolink.test.ts @@ -0,0 +1,164 @@ +import { describe, expect, it } from 'vitest' +import remarkNonHttpsAutolink from '@/lib/remark-non-https-autolink' + +/** + * Unit tests for remark-non-https-autolink. + * + * We test the tree-transform function directly by feeding it MDAST + * structures. The plugin converts raw "scheme://..." text in paragraph + * text nodes into link nodes, skipping http/https (handled by GFM), + * explicit markdown links, and code blocks/inline code. + */ +describe('remarkNonHttpsAutolink', () => { + const transform = remarkNonHttpsAutolink() + + function makeRoot(children: any[]) { return { type: 'root', children } as any } + function makeParagraph(children: any[]) { return { type: 'paragraph', children } as any } + function makeText(value: string) { return { type: 'text', value } as any } + function makeLink(url: string, textValue: string) { + return { type: 'link', url, children: [{ type: 'text', value: textValue }] } as any + } + function makeCode(lang: string, value: string) { return { type: 'code', lang, value } as any } + function makeInlineCode(value: string) { return { type: 'inlineCode', value } as any } + + // ── Basic autolink ─────────────────────────────────────────────────── + + it('converts obsidian:// raw URI in text to a link node', () => { + const tree = makeRoot([makeParagraph([makeText('Check obsidian://open?vault=V&file=F here')])]) + transform(tree) + const para = tree.children[0] + expect(para.children[0]).toMatchObject({ type: 'text', value: 'Check ' }) + expect(para.children[1]).toMatchObject({ type: 'link', url: 'obsidian://open?vault=V&file=F' }) + expect(para.children[2]).toMatchObject({ type: 'text', value: ' here' }) + }) + + it.each([ + ['vscode://file/path/to/file', 'Open vscode://file/path/to/file'], + ['slack://channel?team=T123', 'Join slack://channel?team=T123'], + ])('converts %s URI to a link node', (expectedUrl, inputText) => { + const tree = makeRoot([makeParagraph([makeText(inputText)])]) + transform(tree) + const link = tree.children[0].children.find((c: any) => c.type === 'link') + expect(link?.url).toBe(expectedUrl) + }) + + it('sets the link text to the full URI (autolink style)', () => { + const tree = makeRoot([makeParagraph([makeText('obsidian://vault/file')])]) + transform(tree) + const link = tree.children[0].children[0] + expect(link.children[0].value).toBe('obsidian://vault/file') + }) + + // ── http/https NOT converted (GFM handles those) ───────────────────── + + it.each([ + 'http://example.com', + 'https://example.com', + ])('does NOT convert %s (GFM handles those)', (url) => { + const tree = makeRoot([makeParagraph([makeText(`Visit ${url}`)])]) + transform(tree) + const links = tree.children[0].children.filter((c: any) => c.type === 'link') + expect(links.length).toBe(0) + }) + + // ── Existing link nodes not touched ────────────────────────────────── + + it('does not modify an existing explicit markdown link node', () => { + const existingLink = makeLink('obsidian://open', 'My Note') + const tree = makeRoot([makeParagraph([existingLink])]) + transform(tree) + const para = tree.children[0] + expect(para.children.length).toBe(1) + expect(para.children[0]).toMatchObject({ type: 'link', url: 'obsidian://open' }) + expect(para.children[0].children[0].value).toBe('My Note') + }) + + // ── Code blocks / inline code not touched ──────────────────────────── + + it('does not linkify URIs inside code blocks', () => { + const tree = makeRoot([makeCode('sh', 'open obsidian://vault/file')]) + transform(tree) + expect(tree.children[0].type).toBe('code') + expect(tree.children[0].value).toBe('open obsidian://vault/file') + }) + + it('does not linkify URIs inside inline code', () => { + const tree = makeRoot([makeParagraph([makeInlineCode('obsidian://vault/file')])]) + transform(tree) + expect(tree.children[0].children[0].type).toBe('inlineCode') + }) + + // ── Trailing punctuation handling ──────────────────────────────────── + + it.each([ + ['period', 'See obsidian://open?vault=V.', 'obsidian://open?vault=V', '.'], + ['comma', 'Link obsidian://open,', 'obsidian://open', ','], + ['closing paren', 'See (obsidian://open)', 'obsidian://open', ')'], + ])('strips trailing %s from the linked URL', (_label, inputText, expectedUrl, expectedTrailing) => { + const tree = makeRoot([makeParagraph([makeText(inputText)])]) + transform(tree) + const para = tree.children[0] + const link = para.children.find((c: any) => c.type === 'link') + expect(link?.url).toBe(expectedUrl) + const trailingText = para.children[para.children.indexOf(link) + 1] + expect(trailingText?.value).toContain(expectedTrailing) + }) + + // ── Balanced paren/bracket preservation (GFM autolink literal behaviour) ─── + + it.each([ + ['balanced paren in URL', 'obsidian://open?file=Note(1)', 'obsidian://open?file=Note(1)'], + ['balanced bracket in URL', 'obsidian://open?file=Note[1]', 'obsidian://open?file=Note[1]'], + ['nested balanced parens', 'obsidian://open?q=(a(b)c)', 'obsidian://open?q=(a(b)c)'], + ])('keeps %s inside the linked URL', (_label, inputText, expectedUrl) => { + const tree = makeRoot([makeParagraph([makeText(inputText)])]) + transform(tree) + const link = tree.children[0].children.find((c: any) => c.type === 'link') + expect(link?.url).toBe(expectedUrl) + // No trailing text node should be emitted for a balanced URL. + expect(tree.children[0].children.length).toBe(1) + }) + + it('strips period after a balanced-paren URL but keeps the parens', () => { + const tree = makeRoot([makeParagraph([makeText('See obsidian://open?file=Note(1).')])]) + transform(tree) + const para = tree.children[0] + const link = para.children.find((c: any) => c.type === 'link') + expect(link?.url).toBe('obsidian://open?file=Note(1)') + const trailingText = para.children[para.children.indexOf(link) + 1] + expect(trailingText?.value).toBe('.') + }) + + it('strips an unmatched closing paren (no opener in URL body)', () => { + const tree = makeRoot([makeParagraph([makeText('(see obsidian://x).')])]) + transform(tree) + const para = tree.children[0] + const link = para.children.find((c: any) => c.type === 'link') + expect(link?.url).toBe('obsidian://x') + const trailingText = para.children[para.children.indexOf(link) + 1] + expect(trailingText?.value).toBe(').') + }) + + // ── Multiple URIs in one text node ─────────────────────────────────── + + it('converts multiple non-https URIs in the same text node', () => { + const tree = makeRoot([ + makeParagraph([makeText('A: obsidian://vault/a and B: vscode://file/b done')]) + ]) + transform(tree) + const links = tree.children[0].children.filter((c: any) => c.type === 'link') + expect(links.length).toBe(2) + expect(links[0].url).toBe('obsidian://vault/a') + expect(links[1].url).toBe('vscode://file/b') + }) + + // ── Scheme-only / edge cases ───────────────────────────────────────── + + it('does not linkify a bare scheme without "://"', () => { + // mailto: without // is valid URI but our plugin targets scheme:// only + const tree = makeRoot([makeParagraph([makeText('mailto:user@example.com')])]) + transform(tree) + const links = tree.children[0].children.filter((c: any) => c.type === 'link') + expect(links.length).toBe(0) + }) +}) diff --git a/web/src/lib/remark-non-https-autolink.ts b/web/src/lib/remark-non-https-autolink.ts new file mode 100644 index 00000000..d2172f63 --- /dev/null +++ b/web/src/lib/remark-non-https-autolink.ts @@ -0,0 +1,173 @@ +/** + * Remark plugin that converts raw non-https URI scheme text into link nodes. + * + * GFM (`remark-gfm`) already handles `http://`, `https://`, and `www.` autolinks. + * This plugin handles the remainder: any `scheme://...` pattern where the scheme + * is NOT `http` or `https` (to avoid duplicating GFM's work). + * + * Pipeline position: before `remarkStripCjkAutolink`, before `remarkMath`. + * + * Security note: this plugin deliberately has NO scheme allowlist — it converts + * every `scheme://` pattern it finds. The sanitize layer (`urlTransform`) and + * the onClick layer (`classifyScheme`) handle blocking/confirmation downstream. + * Keeping the plugin allowlist-free means new custom schemes work automatically + * without touching this file. + */ + +// Matches a non-http(s) URI of the form `scheme://...` where: +// - scheme is one or more ASCII letters (a-z), digits, +, -, or . +// - scheme is NOT "http" or "https" (those are GFM's domain) +// - followed by "://" and a run of non-whitespace characters +// +// Trailing punctuation (.,!?;:) and closing brackets/parens are stripped +// by a post-match trim step so "See obsidian://x." doesn't include the ".". +const NON_HTTPS_URI_RE = /\b(?!https?:\/\/)([a-zA-Z][a-zA-Z0-9+\-.]*):\/\/[^\s]*/g + +// Characters that may be stripped from the end of a matched URI. +// `)` and `]` are only stripped when the URL body has no unmatched opening +// counterpart — this mirrors GFM autolink literal behaviour and keeps URIs +// like `obsidian://open?file=Note(1)` intact. +const TRAILING_PUNCT_CHARS = /[.,;!?:)>\]'"]/ + +/** + * Strip trailing punctuation from a URI, but preserve `)` / `]` that close an + * unmatched `(` / `[` inside the URL body. + * + * Examples: + * `obsidian://x.` → stripped `obsidian://x`, trailing `.` + * `obsidian://open?file=Note(1)` → stripped unchanged, trailing `` + * `obsidian://x).` → stripped `obsidian://x`, trailing `).` (no `(` to balance) + */ +function stripTrailingPunct(uri: string): { stripped: string; trailing: string } { + let stripped = uri + let trailing = '' + while (stripped.length > 0) { + const last = stripped[stripped.length - 1] + if (!TRAILING_PUNCT_CHARS.test(last)) break + + if (last === ')' || last === ']') { + const open = last === ')' ? '(' : '[' + const inner = stripped.slice(0, -1) + let opens = 0 + let closes = 0 + for (const ch of inner) { + if (ch === open) opens++ + else if (ch === last) closes++ + } + // If the inner URL already has more opens than closes, the trailing + // closer balances an earlier opener and belongs to the URL. + if (closes < opens) break + } + + trailing = last + trailing + stripped = stripped.slice(0, -1) + } + return { stripped, trailing } +} + +interface MdastNode { + type: string + url?: string + value?: string + lang?: string + children?: MdastNode[] +} + +/** + * Walk all text nodes inside paragraph-like containers and replace + * `scheme://...` patterns with link nodes. + * + * Skips: + * - `code` (fenced code blocks) and `inlineCode` nodes — never touched. + * - `link` / `linkReference` nodes — their children are not re-processed + * (existing links are left as-is). + */ +function visitAndLinkify(node: MdastNode): void { + if (!node.children) return + + const newChildren: MdastNode[] = [] + + for (const child of node.children) { + // Don't descend into existing links or code nodes. + if ( + child.type === 'link' + || child.type === 'linkReference' + || child.type === 'inlineCode' + || child.type === 'code' + ) { + newChildren.push(child) + continue + } + + if (child.type === 'text' && typeof child.value === 'string') { + const segments = linkifyText(child.value) + newChildren.push(...segments) + continue + } + + // Recurse into other container nodes (e.g. paragraph, blockquote, list items). + visitAndLinkify(child) + newChildren.push(child) + } + + node.children = newChildren +} + +/** + * Split a raw text string around any `scheme://...` matches and return a + * mixed array of text nodes and link nodes. + */ +function linkifyText(text: string): MdastNode[] { + const result: MdastNode[] = [] + let lastIndex = 0 + + // Reset the regex state (global flag carries state across calls). + NON_HTTPS_URI_RE.lastIndex = 0 + + let match: RegExpExecArray | null + while ((match = NON_HTTPS_URI_RE.exec(text)) !== null) { + const rawUri = match[0] + const matchStart = match.index + + // Strip trailing punctuation characters from the URI, preserving + // balanced ()/[]. + const { stripped, trailing } = stripTrailingPunct(rawUri) + + // Text before this match. + if (matchStart > lastIndex) { + result.push({ type: 'text', value: text.slice(lastIndex, matchStart) }) + } + + // The link node. + result.push({ + type: 'link', + url: stripped, + children: [{ type: 'text', value: stripped }], + }) + + // Any stripped trailing punctuation becomes a plain text node. + if (trailing) { + result.push({ type: 'text', value: trailing }) + } + + lastIndex = matchStart + rawUri.length + } + + // Remaining text after the last match. + if (lastIndex < text.length) { + result.push({ type: 'text', value: text.slice(lastIndex) }) + } + + // If no matches were found, return the original text node unchanged. + if (result.length === 0) { + result.push({ type: 'text', value: text }) + } + + return result +} + +export default function remarkNonHttpsAutolink() { + return (tree: MdastNode) => { + visitAndLinkify(tree) + } +}