fix(editor): rewrite local images in markdown runs of the split renderer
Every .md preview renders through MarkdownDocument (htmlInfo is ungated), but its markdown segments passed the raw source into MarkdownText, whose chat-security stance drops non-http(s) image destinations — so local markdown-syntax images (, reference-style definitions) always fell back to alt text. Only HTML <img src> tags were rewritten (postProcessSanitized). Apply rewriteLocalImageUrls to each markdown run (segment + appended reference definitions) inside MarkdownDocument's prepared memo, through the same media trust fence the sanitized HTML leaves already use. Idempotent for already-absolute media URLs; remote destinations untouched. Also correct the stale "non-null only for HTML documents" comment on TextEditor's htmlInfo.
This commit is contained in:
@@ -10,9 +10,10 @@
|
||||
* Security posture: every HTML string (block leaves, inline text, wrapper
|
||||
* open-tag attributes) goes through DOMPurify with an explicit denylist on
|
||||
* top of its defaults (no script/style/iframe/forms), anchors are forced to
|
||||
* open in a new tab with noopener, and local media `src` attributes are
|
||||
* rewritten through the session-scoped `/sidebar/file` media route — the same
|
||||
* trust fence the markdown image rewriter (`markdown-images.ts`) uses.
|
||||
* open in a new tab with noopener, and local media goes through the
|
||||
* session-scoped `/sidebar/file` media route — markdown-syntax image
|
||||
* destinations are rewritten via `markdown-images.ts` and `src` attributes
|
||||
* through `resolveLocalMediaDest` (the same trust fence).
|
||||
*/
|
||||
import { useLayoutEffect, useMemo, useRef } from 'react'
|
||||
import { createElement, type ReactNode } from 'react'
|
||||
@@ -21,7 +22,7 @@ import { MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { ComponentType } from 'react'
|
||||
import { markdownTextProps } from './markdown-labels.tsx'
|
||||
import { lazyChunkComponent } from './lazy-chunk.tsx'
|
||||
import { resolveLocalMediaDest } from './markdown-images.ts'
|
||||
import { resolveLocalMediaDest, rewriteLocalImageUrls } from './markdown-images.ts'
|
||||
import {
|
||||
analyzeHtmlSegment,
|
||||
type AnalyzedMarkdownHtml,
|
||||
@@ -235,7 +236,12 @@ export function MarkdownDocument({ info, media, codeLabels }: MarkdownDocumentPr
|
||||
const prepared = useMemo<PreparedSegment[]>(() => info.segments.map((segment): PreparedSegment => {
|
||||
if (segment.kind === 'markdown') {
|
||||
const defs = info.referenceDefinitions
|
||||
const text = defs === '' ? segment.text : `${segment.text}\n\n${defs}`
|
||||
const raw = defs === '' ? segment.text : `${segment.text}\n\n${defs}`
|
||||
// MarkdownText drops non-http(s) image destinations (chat-security
|
||||
// stance), so rewrite local ones into /sidebar/file media URLs first —
|
||||
// the same trust fence the sanitized HTML leaves below go through.
|
||||
// Idempotent: already-absolute media URLs pass through untouched.
|
||||
const text = rewriteLocalImageUrls(raw, media.scope, media.path, media.origin)
|
||||
return {
|
||||
kind: 'markdown',
|
||||
text,
|
||||
|
||||
@@ -346,9 +346,11 @@ export function TextEditor(props: FileViewerProps) {
|
||||
() => (markdown && mode === 'preview' ? splitMermaidBlocks(previewMdText) : []),
|
||||
[markdown, mode, previewMdText],
|
||||
)
|
||||
/** Raw-HTML analysis (block runs lifted out + inline gate). Non-null only
|
||||
* for documents that actually contain HTML — plain markdown keeps the
|
||||
* legacy single-pass render path below, byte-for-byte. */
|
||||
/** Raw-HTML analysis (block runs lifted out + inline gate). Non-null for
|
||||
* every markdown preview, so the render below always takes the split
|
||||
* renderer — its markdown runs rewrite local image destinations internally
|
||||
* (see MarkdownHtml.tsx). The legacy single-pass branches (fed the
|
||||
* pre-rewritten `previewText`) are dead in the current wiring. */
|
||||
const htmlInfo = useMemo(
|
||||
() => (markdown && mode === 'preview' ? analyzeMarkdownHtml(previewMdText) : null),
|
||||
[markdown, mode, previewMdText],
|
||||
|
||||
@@ -175,3 +175,51 @@ describe('MarkdownDocument (inline pass)', () => {
|
||||
await unmount(root)
|
||||
})
|
||||
})
|
||||
|
||||
describe('MarkdownDocument (local markdown images)', () => {
|
||||
it('renders markdown-syntax local images through the /sidebar/file route', async () => {
|
||||
const { container, root } = await renderDocument([
|
||||
'# Title',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
'![logo][logo]',
|
||||
'',
|
||||
'[logo]: ./logo.png',
|
||||
].join('\n'))
|
||||
const imgs = [...container.querySelectorAll('img')]
|
||||
expect(imgs.length, 'both local images must render as <img>, not alt fallback').toBe(2)
|
||||
expect(imgs[0]?.getAttribute('src')).toBe('http://gui.origin/sidebar/file?sessionId=s1&path=%2Fws%2Fdocs%2Fassets%2Ficon.svg&cwd=%2Fws')
|
||||
expect(imgs[0]?.getAttribute('alt')).toBe('icon')
|
||||
expect(imgs[1]?.getAttribute('src')).toBe('http://gui.origin/sidebar/file?sessionId=s1&path=%2Fws%2Fdocs%2Flogo.png&cwd=%2Fws')
|
||||
expect(imgs[1]?.getAttribute('alt')).toBe('logo')
|
||||
await unmount(root)
|
||||
})
|
||||
|
||||
it('renders local markdown images in HTML-mixed documents (the production path)', async () => {
|
||||
const { container, root } = await renderDocument([
|
||||
'<div align="center">badges</div>',
|
||||
'',
|
||||
'',
|
||||
].join('\n'))
|
||||
const img = container.querySelector('img')
|
||||
expect(img, 'the markdown-syntax image after an HTML run must render').not.toBeNull()
|
||||
expect(img?.getAttribute('src')).toBe('http://gui.origin/sidebar/file?sessionId=s1&path=%2Fws%2Fdocs%2Fimg.png&cwd=%2Fws')
|
||||
await unmount(root)
|
||||
})
|
||||
|
||||
it('keeps remote image destinations untouched', async () => {
|
||||
const { container, root } = await renderDocument('')
|
||||
const img = container.querySelector('img')
|
||||
expect(img?.getAttribute('src')).toBe('https://example.com/x.png')
|
||||
await unmount(root)
|
||||
})
|
||||
|
||||
it('is idempotent for already-rewritten media URLs', async () => {
|
||||
const url = 'http://gui.origin/sidebar/file?sessionId=s1&path=%2Fws%2Fdocs%2Fimg.png&cwd=%2Fws'
|
||||
const { container, root } = await renderDocument(``)
|
||||
const img = container.querySelector('img')
|
||||
expect(img?.getAttribute('src')).toBe(url)
|
||||
await unmount(root)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user