fix: restore pasted URL titles through host HTML transport · marktext/marktext@66bb074 · GitHub
Skip to content

Commit 66bb074

Browse files
committed
fix: restore pasted URL titles through host HTML transport
1 parent d0428e9 commit 66bb074

15 files changed

Lines changed: 352 additions & 47 deletions

File tree

‎docs/assets/paste-url-title.png‎

69 KB
Loading

‎packages/desktop/src/main/ipc/index.ts‎

Lines changed: 2 additions & 0 deletions
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { ipcMain, net } from 'electron'
2+
3+
const TIMEOUT_MS = 1500
4+
const MAX_BYTES = 1024 * 1024
5+
6+
type FetchLinkHtml = (url: string, init: RequestInit) => Promise<Response>
7+
8+
export const fetchLinkHtml = async(url: string, fetchImpl: FetchLinkHtml): Promise<string> => {
9+
let parsed: URL
10+
try {
11+
parsed = new URL(url)
12+
} catch {
13+
return ''
14+
}
15+
if (!['http:', 'https:'].includes(parsed.protocol)) return ''
16+
17+
const controller = new AbortController()
18+
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS)
19+
try {
20+
const response = await fetchImpl(url, {
21+
signal: controller.signal,
22+
credentials: 'omit'
23+
})
24+
if (
25+
response.status !== 200 ||
26+
!/^text\/html\b/i.test(response.headers.get('content-type') ?? '')
27+
) {
28+
await response.body?.cancel()
29+
return ''
30+
}
31+
const reader = response.body?.getReader()
32+
if (!reader) return ''
33+
const decoder = new TextDecoder()
34+
let html = ''
35+
let size = 0
36+
try {
37+
for (;;) {
38+
const { value, done } = await reader.read()
39+
if (done) return html + decoder.decode()
40+
size += value.byteLength
41+
if (size > MAX_BYTES) return ''
42+
html += decoder.decode(value, { stream: true })
43+
}
44+
} finally {
45+
// Stop oversized or interrupted responses instead of leaving them streaming.
46+
await reader.cancel().catch(() => {})
47+
}
48+
} catch {
49+
return ''
50+
} finally {
51+
clearTimeout(timer)
52+
}
53+
}
54+
55+
export const registerLinkHtmlHandler = (): void => {
56+
ipcMain.handle('mt::link::fetch-html', (_event, url: string) => fetchLinkHtml(url, net.fetch))
57+
}

‎packages/desktop/src/preload/index.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,7 @@ const electronAPI = {
244244
ipcRenderer: ipcWrapper,
245245
shell: shellAPI,
246246
clipboard: clipboardAPI,
247+
fetchLinkHtml: (url: string) => invoke('mt::link::fetch-html', url),
247248
webFrame: webFrameAPI,
248249
webUtils: webUtilsAPI,
249250
process: {

‎packages/desktop/src/renderer/src/components/editorWithTabs/editor.vue‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1804,6 +1804,7 @@ onMounted(() => {
18041804
clipboardFilePath: guessClipboardFilePath,
18051805
// Read the OS clipboard's plain text for "Paste as Plain Text" (execCommand('paste') no longer fires).
18061806
clipboardText: () => window.electron.clipboard.readText(),
1807+
fetchLinkHtml: (url) => window.electron.fetchLinkHtml(url),
18071808
// Image-persist callbacks read by the engine's clipboard + drag-drop handlers
18081809
// from `muya.options.*` (distinct from the ImageEditTool plugin option above).
18091810
// Without these, local-file drag-drop, screenshot/binary clipboard paste, and

‎packages/desktop/src/shared/types/ipc.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ export interface IpcInvokeChannels {
5454
'mt::boot-info-async': { args: []; ret: BootInfo }
5555
'mt::clipboard::guess-file-path': { args: []; ret: string | null }
5656
'mt::clipboard::read-text': { args: []; ret: string }
57+
'mt::link::fetch-html': { args: [url: string]; ret: string }
5758
'mt::clipboard::write-image': { args: [png: Uint8Array]; ret: boolean }
5859
'mt::cmd::exists': { args: [name: string]; ret: boolean }
5960
'mt::diagram::fetch-plantuml': {

‎packages/desktop/src/types/global.d.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ declare global {
8888
ipcRenderer: ElectronIpcRenderer
8989
shell: ElectronShellAPI
9090
clipboard: ElectronClipboardAPI
91+
fetchLinkHtml(url: string): Promise<string>
9192
webFrame: ElectronWebFrameAPI
9293
webUtils: ElectronWebUtilsAPI
9394
process: {
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import { createServer, type Server } from 'node:http'
2+
import { expect, test } from '@playwright/test'
3+
import type { ElectronApplication, Page } from 'playwright'
4+
import { getMarkdownContent, launchWithMarkdown, placeCaretInEditor } from './helpers'
5+
6+
let app: ElectronApplication
7+
let page: Page
8+
let server: Server
9+
let url: string
10+
let requests: string[]
11+
12+
test.beforeAll(async() => {
13+
requests = []
14+
server = createServer((request, response) => {
15+
requests.push(request.url ?? '')
16+
response.setHeader('content-type', 'text/html; charset=utf-8')
17+
response.end('<!doctype html><title>Example &amp; page title</title><p>Content</p>')
18+
})
19+
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve))
20+
const address = server.address()
21+
if (!address || typeof address === 'string') throw new Error('Missing test server port')
22+
url = `http://127.0.0.1:${address.port}`
23+
})
24+
25+
test.beforeEach(async() => {
26+
const launched = await launchWithMarkdown('\n')
27+
app = launched.app
28+
page = launched.page
29+
await placeCaretInEditor(page)
30+
requests.length = 0
31+
})
32+
33+
test.afterEach(async() => {
34+
if (app) await app.close()
35+
})
36+
37+
test.afterAll(async() => {
38+
server.closeAllConnections()
39+
await new Promise<void>((resolve) => server.close(() => resolve()))
40+
})
41+
42+
const paste = async(text: string) => {
43+
await page.evaluate((text) => {
44+
const target = document.querySelector('.mu-editor[contenteditable="true"]')
45+
if (!target) throw new Error('Missing editor')
46+
const data = new DataTransfer()
47+
data.setData('text/plain', text)
48+
target.dispatchEvent(new ClipboardEvent('paste', { clipboardData: data, bubbles: true, cancelable: true }))
49+
}, text)
50+
}
51+
52+
test('pasted URL gets its title despite renderer CSP and absent CORS headers', async() => {
53+
expect(await page.evaluate(async(url) => {
54+
try { await fetch(url); return true } catch { return false }
55+
}, url)).toBe(false)
56+
expect(requests).toEqual([])
57+
58+
await paste(url)
59+
await expect(page.locator('.editor-component .mu-link').filter({ hasText: 'Example & page title' })).toHaveCount(1)
60+
expect(requests).toEqual(['/'])
61+
await page.screenshot({ path: test.info().outputPath('paste-url-title.png') })
62+
expect(await getMarkdownContent(page, app)).toContain(`[Example & page title](${url})`)
63+
})
64+
65+
test('plain-text paste does not fetch a page title', async() => {
66+
await app.evaluate(({ clipboard }, value) => clipboard.writeText(value), url)
67+
await app.evaluate(({ BrowserWindow }) => {
68+
BrowserWindow.getAllWindows()[0].webContents.send('mt::cm-paste-as-plain-text')
69+
})
70+
await expect(page.locator('.editor-component')).toContainText(url)
71+
expect(requests).toEqual([])
72+
expect(await getMarkdownContent(page, app)).not.toContain('Example & page title')
73+
})
74+
75+
test('failed title lookup preserves the URL', async() => {
76+
await paste('http://127.0.0.1:1/unreachable')
77+
await expect(page.locator('.editor-component')).toContainText('http://127.0.0.1:1/unreachable')
78+
})
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { describe, expect, it, vi } from 'vitest'
2+
import { fetchLinkHtml, registerLinkHtmlHandler } from 'main_renderer/ipc/linkHtml'
3+
4+
const { handle, fetchImpl } = vi.hoisted(() => ({ handle: vi.fn(), fetchImpl: vi.fn() }))
5+
vi.mock('electron', () => ({ ipcMain: { handle }, net: { fetch: fetchImpl } }))
6+
7+
const htmlResponse = (body: string, init: ResponseInit = {}) =>
8+
new Response(body, { headers: { 'content-type': 'text/html; charset=utf-8' }, ...init })
9+
10+
describe('link HTML transport', () => {
11+
it('returns HTML through a registered IPC handler without sending session cookies', async() => {
12+
registerLinkHtmlHandler()
13+
const handler = handle.mock.calls.find(([channel]) => channel === 'mt::link::fetch-html')?.[1]
14+
fetchImpl.mockResolvedValueOnce(htmlResponse('<title>Example &amp; title</title>'))
15+
expect(await handler({}, 'https://example.com')).toBe('<title>Example &amp; title</title>')
16+
expect(fetchImpl).toHaveBeenCalledWith('https://example.com', {
17+
signal: expect.any(AbortSignal), credentials: 'omit'
18+
})
19+
})
20+
21+
it.each(['file:///etc/passwd', 'javascript:alert(1)', 'data:text/html,test', 'invalid'])('rejects %s', async(url) => {
22+
const fetcher = vi.fn()
23+
expect(await fetchLinkHtml(url, fetcher)).toBe('')
24+
expect(fetcher).not.toHaveBeenCalled()
25+
})
26+
27+
it.each([
28+
new Response('missing', { status: 404 }),
29+
new Response('{}', { headers: { 'content-type': 'application/json' } })
30+
])('ignores unsuccessful or non-HTML responses', async(response) => {
31+
expect(await fetchLinkHtml('https://example.com', async() => response)).toBe('')
32+
})
33+
34+
it('stops reading oversized responses', async() => {
35+
const cancel = vi.fn()
36+
const body = new ReadableStream<Uint8Array>({
37+
start(controller) { controller.enqueue(new Uint8Array(1024 * 1024 + 1)) },
38+
cancel
39+
})
40+
const response = new Response(body, { headers: { 'content-type': 'text/html' } })
41+
expect(await fetchLinkHtml('https://example.com', async() => response)).toBe('')
42+
expect(cancel).toHaveBeenCalled()
43+
})
44+
45+
it('falls back on a network failure', async() => {
46+
expect(await fetchLinkHtml('https://example.com', async() => { throw new Error('offline') })).toBe('')
47+
})
48+
49+
it('aborts a slow network request', async() => {
50+
vi.useFakeTimers()
51+
try {
52+
let signal: AbortSignal | null | undefined
53+
const result = fetchLinkHtml('https://example.com', async(_url, init) => {
54+
signal = init.signal
55+
return new Promise((_resolve, reject) => {
56+
init.signal?.addEventListener('abort', () => reject(new Error('aborted')))
57+
})
58+
})
59+
await vi.advanceTimersByTimeAsync(1500)
60+
expect(await result).toBe('')
61+
expect(signal?.aborted).toBe(true)
62+
} finally {
63+
vi.useRealTimers()
64+
}
65+
})
66+
})

‎packages/muya/README.md‎

Lines changed: 22 additions & 0 deletions

0 commit comments

Comments
 (0)