mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-14 03:56:55 +03:00
fix(app): render local Markdown images through the server (#45780)
This commit is contained in:
committed by
GitHub
Unverified
parent
d5be4cf888
commit
c0fa9aff72
@@ -0,0 +1,135 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { createApiForServer } from "./api"
|
||||
import { readLocalImage } from "./image"
|
||||
|
||||
function setup(
|
||||
respond: (init?: RequestInit) => Response | Promise<Response> = () => new Response(new Uint8Array([0, 127, 255])),
|
||||
) {
|
||||
const requests: Array<{ url: URL; init?: RequestInit }> = []
|
||||
const api = createApiForServer({
|
||||
server: { url: "https://server.example:4096", username: "image-user", password: "secret" },
|
||||
fetch: (async (input: string | URL | Request, init?: RequestInit) => {
|
||||
requests.push({ url: new URL(input instanceof Request ? input.url : input), init })
|
||||
return respond(init)
|
||||
}) as typeof fetch,
|
||||
})
|
||||
return { api, requests }
|
||||
}
|
||||
|
||||
describe("readLocalImage", () => {
|
||||
test.each([
|
||||
["images/screen #1 + 50% ?.PNG", "/workspace/project", "images/screen%20%231%20%2B%2050%25%20%3F.PNG"],
|
||||
["C:/tmp/opencode/screen #1.png", "C:/tmp/opencode/", "screen%20%231.png"],
|
||||
["d:/screen.png", "d:/", "screen.png"],
|
||||
["D:/charts/screen.png", "D:/charts/", "screen.png"],
|
||||
["Z:/charts/screen.png", "Z:/charts/", "screen.png"],
|
||||
["/tmp/opencode/screen #1.png", "/tmp/opencode/", "screen%20%231.png"],
|
||||
["/screen.png", "/", "screen.png"],
|
||||
])("reads %s with the correct location and authentication", async (path, directory, encoded) => {
|
||||
const { api, requests } = setup()
|
||||
const signal = new AbortController().signal
|
||||
const blob = await readLocalImage(api, "/workspace/project", path, signal)
|
||||
|
||||
expect(blob).toBeInstanceOf(Blob)
|
||||
expect(blob?.type).toBe("image/png")
|
||||
expect(new Uint8Array(await blob!.arrayBuffer())).toEqual(new Uint8Array([0, 127, 255]))
|
||||
expect(requests).toHaveLength(1)
|
||||
expect(requests[0].url.origin).toBe("https://server.example:4096")
|
||||
expect(requests[0].url.pathname).toBe(`/api/fs/read/${encoded}`)
|
||||
expect([...requests[0].url.searchParams]).toEqual([["location[directory]", directory]])
|
||||
expect(requests[0].init?.method).toBe("GET")
|
||||
expect(new Headers(requests[0].init?.headers).get("authorization")).toBe(`Basic ${btoa("image-user:secret")}`)
|
||||
expect(requests[0].init?.signal).toBe(signal)
|
||||
})
|
||||
|
||||
test("preserves literal percent escapes and encodes the supplied relative location", async () => {
|
||||
const { api, requests } = setup()
|
||||
await readLocalImage(api, "C:/project #1 + 50%", "images/literal%20name.png", new AbortController().signal)
|
||||
|
||||
expect(requests[0].url.href).toBe(
|
||||
"https://server.example:4096/api/fs/read/images/literal%2520name.png?location%5Bdirectory%5D=C%3A%2Fproject+%231+%2B+50%25",
|
||||
)
|
||||
})
|
||||
|
||||
test.each([
|
||||
["png", "image/png"],
|
||||
["jpeg", "image/jpeg"],
|
||||
["JPG", "image/jpeg"],
|
||||
["gif", "image/gif"],
|
||||
["webp", "image/webp"],
|
||||
["SVG", "image/svg+xml"],
|
||||
["avif", "image/avif"],
|
||||
["bmp", "image/bmp"],
|
||||
["ico", "image/x-icon"],
|
||||
])("uses the MIME type for .%s rather than the response header", async (extension, type) => {
|
||||
const { api } = setup(
|
||||
() => new Response("image bytes", { headers: { "content-type": "application/octet-stream" } }),
|
||||
)
|
||||
const blob = await readLocalImage(api, "/repo", `image.${extension}`, new AbortController().signal)
|
||||
expect(blob?.type).toBe(type)
|
||||
expect(await blob?.text()).toBe("image bytes")
|
||||
})
|
||||
|
||||
test.each([
|
||||
"https://example.com/image.png",
|
||||
"HTTP://example.com/image.png",
|
||||
"file:///tmp/image.png",
|
||||
"data:image/png;base64,image.png",
|
||||
"blob:https://example.com/image.png",
|
||||
"custom+scheme:image.png",
|
||||
"C:image.png",
|
||||
"//server/share/image.png",
|
||||
"\\\\server\\share\\image.png",
|
||||
"//?/C:/image.png",
|
||||
"///server/share/image.png",
|
||||
"image.txt",
|
||||
"image.html",
|
||||
"image.constructor",
|
||||
"image.png.txt",
|
||||
"image",
|
||||
"images.png/image",
|
||||
"image.png/",
|
||||
"",
|
||||
])("does not request unsupported input %s", async (path) => {
|
||||
const { api, requests } = setup()
|
||||
expect(await readLocalImage(api, "/repo", path, new AbortController().signal)).toBeUndefined()
|
||||
expect(requests).toHaveLength(0)
|
||||
})
|
||||
|
||||
test.each([400, 401])("propagates declared API errors (%s)", async (status) => {
|
||||
const error = { _tag: "RequestError", message: "Cannot read image" }
|
||||
const { api } = setup(() => Response.json(error, { status }))
|
||||
await expect(readLocalImage(api, "/repo", "image.png", new AbortController().signal)).rejects.toEqual(error)
|
||||
})
|
||||
|
||||
test.each([404, 500])("does not turn an unexpected HTTP status (%s) into a Blob", async (status) => {
|
||||
const { api } = setup(() => new Response("Not an image", { status }))
|
||||
await expect(readLocalImage(api, "/repo", "image.png", new AbortController().signal)).rejects.toMatchObject({
|
||||
reason: "UnexpectedStatus",
|
||||
cause: { status },
|
||||
})
|
||||
})
|
||||
|
||||
test("propagates transport failures", async () => {
|
||||
const error = new Error("Connection lost")
|
||||
const { api } = setup(() => Promise.reject(error))
|
||||
await expect(readLocalImage(api, "/repo", "image.png", new AbortController().signal)).rejects.toMatchObject({
|
||||
reason: "Transport",
|
||||
cause: error,
|
||||
})
|
||||
})
|
||||
|
||||
test("forwards cancellation to an in-flight fetch", async () => {
|
||||
const controller = new AbortController()
|
||||
const { api, requests } = setup(
|
||||
(init) =>
|
||||
new Promise((_resolve, reject) => {
|
||||
init?.signal?.addEventListener("abort", () => reject(init.signal?.reason), { once: true })
|
||||
}),
|
||||
)
|
||||
const result = readLocalImage(api, "/repo", "image.png", controller.signal)
|
||||
expect(requests[0].init?.signal).toBe(controller.signal)
|
||||
controller.abort()
|
||||
await expect(result).rejects.toMatchObject({ reason: "Transport", cause: controller.signal.reason })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,36 @@
|
||||
import { getDirectory, getFilename } from "@opencode-ai/util/path"
|
||||
import type { OpenCodeClient } from "@opencode-ai/client/promise"
|
||||
|
||||
const types = new Map([
|
||||
["png", "image/png"],
|
||||
["jpeg", "image/jpeg"],
|
||||
["jpg", "image/jpeg"],
|
||||
["gif", "image/gif"],
|
||||
["webp", "image/webp"],
|
||||
["svg", "image/svg+xml"],
|
||||
["avif", "image/avif"],
|
||||
["bmp", "image/bmp"],
|
||||
["ico", "image/x-icon"],
|
||||
])
|
||||
|
||||
export async function readLocalImage(
|
||||
api: Pick<OpenCodeClient, "file">,
|
||||
directory: string,
|
||||
path: string,
|
||||
signal: AbortSignal,
|
||||
): Promise<Blob | undefined> {
|
||||
const drive = /^[a-z]:\//i.test(path)
|
||||
if (/^[\\/]{2}/.test(path) || (!drive && /^[a-z][a-z\d+.-]*:/i.test(path))) return
|
||||
const type = types.get(path.match(/\.([^./]+)$/)?.[1]?.toLowerCase() ?? "")
|
||||
if (!type) return
|
||||
const absolute = drive || path.startsWith("/")
|
||||
// Scope absolute images to their parent, including files outside the project.
|
||||
const bytes = await api.file.read(
|
||||
{
|
||||
path: absolute ? getFilename(path) : path,
|
||||
location: { directory: absolute ? getDirectory(path) : directory },
|
||||
},
|
||||
{ signal },
|
||||
)
|
||||
return new Blob([new Uint8Array(bytes)], { type })
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { DataProvider } from "@opencode-ai/session-ui/context"
|
||||
import { MarkdownProvider, type ReadMarkdownImage } from "@opencode-ai/session-ui/context/markdown"
|
||||
import { useNavigate, useParams } from "@solidjs/router"
|
||||
import { createMemo, type ParentProps } from "solid-js"
|
||||
import { useProviders } from "@/providers/catalog/providers"
|
||||
@@ -8,6 +9,7 @@ import { sessionHref } from "@/shell/routes/session"
|
||||
import { useData } from "@/runtime/server/current"
|
||||
import { useServerSDK } from "@/runtime/server/client"
|
||||
import { useTabs } from "@/shell/tabs/tabs"
|
||||
import { readLocalImage } from "@/runtime/server/image"
|
||||
|
||||
export function SessionUIProvider(
|
||||
props: ParentProps<{
|
||||
@@ -21,6 +23,10 @@ export function SessionUIProvider(
|
||||
const serverSDK = useServerSDK()
|
||||
const tabs = useTabs()
|
||||
const directory = () => props.directory
|
||||
const readImage = createMemo<ReadMarkdownImage>(() => {
|
||||
const dir = directory()
|
||||
return (path, signal) => readLocalImage(serverSDK.api, dir, path, signal)
|
||||
})
|
||||
const href = (sessionID: string) => sessionHref(props.server, sessionID)
|
||||
const navigateToSession = async (sessionID: string) => {
|
||||
const tab = tabs.store.find(
|
||||
@@ -60,7 +66,9 @@ export function SessionUIProvider(
|
||||
onNavigateToSession={navigateToSession}
|
||||
onSessionHref={href}
|
||||
>
|
||||
<LocalProvider>{props.children}</LocalProvider>
|
||||
<MarkdownProvider readImage={readImage()}>
|
||||
<LocalProvider>{props.children}</LocalProvider>
|
||||
</MarkdownProvider>
|
||||
</DataProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,16 +2,28 @@ import { createSignal, Show } from "solid-js"
|
||||
import { render } from "solid-js/web"
|
||||
import { Markdown } from "../src/components/markdown"
|
||||
import { preloadMarkdown } from "../src/components/markdown-cache"
|
||||
import { MarkdownProvider } from "../src/context/markdown"
|
||||
import { OpenCode } from "@opencode-ai/client/promise"
|
||||
import { readLocalImage } from "../../app/src/runtime/server/image"
|
||||
|
||||
export { sanitizeMarkdown } from "../src/components/markdown-cache"
|
||||
export { renderMermaidSvg } from "../src/components/markdown-mermaid"
|
||||
|
||||
export async function mountMarkdown(options: { text: string; streaming?: boolean; cached?: boolean }) {
|
||||
export async function mountMarkdown(options: {
|
||||
text: string
|
||||
streaming?: boolean
|
||||
cached?: boolean
|
||||
images?: boolean
|
||||
}) {
|
||||
if (options.cached) await preloadMarkdown(options.text, "markdown-test")
|
||||
const host = document.createElement("div")
|
||||
host.dataset.testid = "markdown-fixture"
|
||||
document.body.appendChild(host)
|
||||
render(() => {
|
||||
const api = OpenCode.make({
|
||||
baseUrl: location.origin,
|
||||
headers: { Authorization: `Basic ${btoa("opencode:fixture")}` },
|
||||
})
|
||||
const [text, setText] = createSignal(options.text)
|
||||
const [streaming, setStreaming] = createSignal(options.streaming ?? false)
|
||||
const [visible, setVisible] = createSignal(true)
|
||||
@@ -25,14 +37,20 @@ export async function mountMarkdown(options: { text: string; streaming?: boolean
|
||||
onChange={(event) => setStreaming(event.currentTarget.checked)}
|
||||
/>
|
||||
<button onClick={() => setVisible((value) => !value)}>Toggle Markdown</button>
|
||||
<Show when={visible()}>
|
||||
<Markdown
|
||||
text={text()}
|
||||
streaming={streaming()}
|
||||
cacheKey={options.cached ? "markdown-test" : undefined}
|
||||
deferUntilReady
|
||||
/>
|
||||
</Show>
|
||||
<MarkdownProvider
|
||||
readImage={(path, signal) =>
|
||||
options.images ? readLocalImage(api, "C:/project", path, signal) : Promise.resolve(undefined)
|
||||
}
|
||||
>
|
||||
<Show when={visible()}>
|
||||
<Markdown
|
||||
text={text()}
|
||||
streaming={streaming()}
|
||||
cacheKey={options.cached ? "markdown-test" : undefined}
|
||||
deferUntilReady
|
||||
/>
|
||||
</Show>
|
||||
</MarkdownProvider>
|
||||
</>
|
||||
)
|
||||
}, host)
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { expect, story } from "../../storybook/playwright/story"
|
||||
|
||||
const png = Buffer.from(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+a4ioAAAAASUVORK5CYII=",
|
||||
"base64",
|
||||
)
|
||||
|
||||
const fixture = `/@fs/${fileURLToPath(new URL("./markdown.fixture.tsx", import.meta.url)).replaceAll("\\", "/")}`
|
||||
|
||||
story.beforeEach(async ({ mount }) => {
|
||||
@@ -22,7 +27,7 @@ story("sanitizes raw HTML while preserving supported Markdown markup", async ({
|
||||
}, fixture)
|
||||
expect(result).toEqual([
|
||||
"<p><strong>Safe</strong> <em>formatting</em> <code>const x = 1</code></p>",
|
||||
'<img src="safe.png"><a>unsafe</a>',
|
||||
'<img data-local-image="safe.png"><a>unsafe</a>',
|
||||
'<a href="https://example.com" target="_blank" rel="nofollow noopener noreferrer">external</a><a href="/local">local</a>',
|
||||
'<form name="user-content-document" id="user-content-location"><input name="user-content-cookie"></form>',
|
||||
"<math><mrow><mi>x</mi><mo>+</mo><mn>1</mn></mrow></math>",
|
||||
@@ -64,7 +69,7 @@ story("keeps Markdown sanitization and link protections after Mermaid renders",
|
||||
}
|
||||
}, fixture)
|
||||
expect(result.before).toBe(
|
||||
'<a href="https://example.com" target="_blank" rel="nofollow noopener noreferrer">external</a><img src="safe.png">',
|
||||
'<a href="https://example.com" target="_blank" rel="nofollow noopener noreferrer">external</a><img data-local-image="safe.png">',
|
||||
)
|
||||
expect(result.renders).toEqual([
|
||||
{ root: "svg", text: expect.stringContaining("Start"), unsafe: 0, links: [], markdown: result.before },
|
||||
@@ -211,3 +216,155 @@ story("replaces completed DOM before live rendering and retains streamed code co
|
||||
await harness.getByRole("button", { name: "Toggle Markdown" }).click()
|
||||
await expect(markdown).toHaveCount(0)
|
||||
})
|
||||
|
||||
for (const streaming of [false, true]) {
|
||||
story(
|
||||
`loads local images in ${streaming ? "streaming" : "cached"} Markdown and releases their URLs`,
|
||||
async ({ page }) => {
|
||||
const requests: string[] = []
|
||||
await page.route("**/api/fs/read/**", async (route) => {
|
||||
expect(route.request().headers().authorization).toBe(
|
||||
`Basic ${Buffer.from("opencode:fixture").toString("base64")}`,
|
||||
)
|
||||
requests.push(route.request().url())
|
||||
await route.fulfill({ contentType: "image/png", body: png })
|
||||
})
|
||||
await page.evaluate(
|
||||
async ({ fixture, streaming }) => {
|
||||
const { mountMarkdown } = await import(fixture)
|
||||
await mountMarkdown({
|
||||
text: "\n\n",
|
||||
streaming,
|
||||
cached: !streaming,
|
||||
images: true,
|
||||
})
|
||||
},
|
||||
{ fixture, streaming },
|
||||
)
|
||||
const harness = page.getByTestId("markdown-fixture")
|
||||
const image = harness.getByRole("img", { name: "Chart", exact: true })
|
||||
await expect.poll(() => image.evaluate((image: HTMLImageElement) => image.naturalWidth)).toBe(1)
|
||||
await expect(harness.getByRole("img", { name: "Again", exact: true })).toHaveAttribute("src", /^blob:/)
|
||||
expect(requests).toHaveLength(1)
|
||||
expect(new URL(requests[0]).searchParams.get("location[directory]")).toBe("C:/tmp/")
|
||||
const url = await image.getAttribute("src")
|
||||
await harness.getByLabel("Streaming").uncheck()
|
||||
await expect(harness.locator('[data-component="markdown"]')).toHaveAttribute("data-markdown-ready", "")
|
||||
await expect(image).toHaveAttribute("src", url!)
|
||||
await harness.getByLabel("Markdown text").fill("")
|
||||
await expect
|
||||
.poll(() =>
|
||||
harness.getByRole("img", { name: "Replacement" }).evaluate((image: HTMLImageElement) => image.naturalWidth),
|
||||
)
|
||||
.toBe(1)
|
||||
expect(requests).toHaveLength(2)
|
||||
expect(
|
||||
await page.evaluate(
|
||||
(url) =>
|
||||
fetch(url!).then(
|
||||
() => false,
|
||||
() => true,
|
||||
),
|
||||
url,
|
||||
),
|
||||
).toBe(true)
|
||||
const next = await harness.getByRole("img", { name: "Replacement" }).getAttribute("src")
|
||||
await harness.getByRole("button", { name: "Toggle Markdown" }).click()
|
||||
await expect(harness.getByRole("img")).toHaveCount(0)
|
||||
expect(
|
||||
await page.evaluate(
|
||||
(url) =>
|
||||
fetch(url!).then(
|
||||
() => false,
|
||||
() => true,
|
||||
),
|
||||
next,
|
||||
),
|
||||
).toBe(true)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
story("keeps remote images browser-owned and rejects unsafe image sources", async ({ page }) => {
|
||||
await page.route("https://images.example/chart.png", (route) => {
|
||||
expect(route.request().headers().authorization).toBeUndefined()
|
||||
return route.fulfill({ contentType: "image/png", body: png })
|
||||
})
|
||||
await page.evaluate(async (fixture) => {
|
||||
const { mountMarkdown } = await import(fixture)
|
||||
await mountMarkdown({
|
||||
images: true,
|
||||
text: '<img alt="Remote" src="https://images.example/chart.png"><img alt="Unsafe" src="javascript:alert(1)" onerror="alert(2)" data-local-image="/tmp/forged.png">',
|
||||
})
|
||||
}, fixture)
|
||||
const harness = page.getByTestId("markdown-fixture")
|
||||
await expect
|
||||
.poll(() => harness.getByRole("img", { name: "Remote" }).evaluate((image: HTMLImageElement) => image.naturalWidth))
|
||||
.toBe(1)
|
||||
await expect(harness.getByRole("img", { name: "Remote" })).toHaveAttribute("src", "https://images.example/chart.png")
|
||||
await expect(harness.locator("[onerror], [src^='javascript:'], [data-local-image]")).toHaveCount(0)
|
||||
})
|
||||
|
||||
story("keeps scripts and external resources inactive inside local SVG images", async ({ page }) => {
|
||||
const external: string[] = []
|
||||
await page.context().route("https://images.example/**", async (route) => {
|
||||
external.push(route.request().url())
|
||||
await route.abort()
|
||||
})
|
||||
await page.route("**/api/fs/read/chart.svg?*", (route) =>
|
||||
route.fulfill({
|
||||
contentType: "image/svg+xml",
|
||||
body: `<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32">
|
||||
<script>parent.document.title = "image-script-ran"; fetch("https://images.example/script")</script>
|
||||
<image href="https://images.example/nested.png" width="32" height="32" />
|
||||
<rect width="32" height="32" fill="green" />
|
||||
</svg>`,
|
||||
}),
|
||||
)
|
||||
await page.evaluate(async (fixture) => {
|
||||
const { mountMarkdown } = await import(fixture)
|
||||
await mountMarkdown({ images: true, text: "" })
|
||||
}, fixture)
|
||||
const image = page.getByTestId("markdown-fixture").getByRole("img", { name: "SVG" })
|
||||
await expect.poll(() => image.evaluate((image: HTMLImageElement) => image.naturalWidth)).toBe(32)
|
||||
await expect(image).toHaveAttribute("src", /^data:image\/svg\+xml;/)
|
||||
await expect(page).not.toHaveTitle("image-script-ran")
|
||||
expect(external).toEqual([])
|
||||
// Opening an image as a document must not give its contents the app's origin.
|
||||
const preview = await page.context().newPage()
|
||||
await preview.goto((await image.getAttribute("src"))!)
|
||||
expect(await preview.evaluate(() => location.origin)).toBe("null")
|
||||
await preview.close()
|
||||
})
|
||||
|
||||
story("loads file URLs and leaves unreadable images as alt text", async ({ page }) => {
|
||||
const requested = new Set<string>()
|
||||
await page.route("**/api/fs/read/**", async (route) => {
|
||||
const url = new URL(route.request().url())
|
||||
requested.add(url.pathname)
|
||||
await route.fulfill(
|
||||
url.pathname.endsWith("missing.png")
|
||||
? { status: 404, body: "Not found" }
|
||||
: { contentType: "image/png", body: png },
|
||||
)
|
||||
})
|
||||
await page.evaluate(async (fixture) => {
|
||||
const { mountMarkdown } = await import(fixture)
|
||||
await mountMarkdown({
|
||||
images: true,
|
||||
text: "\n\n",
|
||||
})
|
||||
}, fixture)
|
||||
const harness = page.getByTestId("markdown-fixture")
|
||||
await expect
|
||||
.poll(() =>
|
||||
harness
|
||||
.getByRole("img", { name: "Available", exact: true })
|
||||
.evaluate((image: HTMLImageElement) => image.naturalWidth),
|
||||
)
|
||||
.toBe(1)
|
||||
await expect.poll(() => [...requested].sort()).toEqual(["/api/fs/read/chart%25.png", "/api/fs/read/missing.png"])
|
||||
await expect(harness.getByRole("img", { name: "Unavailable" })).not.toHaveAttribute("src")
|
||||
await harness.getByLabel("Markdown text").fill("Still usable")
|
||||
await expect(harness.locator('[data-component="markdown"]')).toHaveText("Still usable")
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { checksum } from "@opencode-ai/util/encode"
|
||||
import DOMPurify from "dompurify"
|
||||
import { parseMarkdown } from "./markdown-worker"
|
||||
import { localImagePath } from "./markdown-image"
|
||||
|
||||
export type MarkdownCacheEntry = {
|
||||
raw: string
|
||||
@@ -22,6 +23,16 @@ const config = {
|
||||
}
|
||||
|
||||
if (typeof window !== "undefined" && purifier.isSupported) {
|
||||
purifier.addHook("beforeSanitizeAttributes", (node) => {
|
||||
if (!(node instanceof HTMLImageElement)) return
|
||||
// Local paths are not browser URLs. Keep them inert until the host reads them.
|
||||
node.removeAttribute("data-local-image")
|
||||
const path = localImagePath(node.getAttribute("src") ?? "")
|
||||
if (!path) return
|
||||
node.setAttribute("data-local-image", path)
|
||||
node.removeAttribute("src")
|
||||
node.removeAttribute("srcset")
|
||||
})
|
||||
purifier.addHook("afterSanitizeAttributes", (node: Element) => {
|
||||
if (!(node instanceof HTMLAnchorElement)) return
|
||||
if (node.target !== "_blank") return
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { localImagePath } from "./markdown-image"
|
||||
|
||||
test.each([
|
||||
["C:/tmp/chart.png", "C:/tmp/chart.png"],
|
||||
["C:\\tmp\\chart.png", "C:/tmp/chart.png"],
|
||||
["D:/charts/chart.png", "D:/charts/chart.png"],
|
||||
["Z:\\charts\\chart.png", "Z:/charts/chart.png"],
|
||||
["file:///C:/tmp/chart%20one.png", "C:/tmp/chart one.png"],
|
||||
["file:///d:/charts/chart%20one.png", "d:/charts/chart one.png"],
|
||||
["file:///tmp/chart.png", "/tmp/chart.png"],
|
||||
["file://localhost/tmp/chart.png", "/tmp/chart.png"],
|
||||
["/tmp/chart.png", "/tmp/chart.png"],
|
||||
["./images/chart%20one.svg", "./images/chart one.svg"],
|
||||
["chart.png", "chart.png"],
|
||||
["./chart%25.png", "./chart%.png"],
|
||||
])("recognizes local image %s", (source, path) => {
|
||||
expect(localImagePath(source)).toBe(path)
|
||||
})
|
||||
|
||||
test.each([
|
||||
"https://example.com/chart.png",
|
||||
"http://example.com/chart.png",
|
||||
"//example.com/chart.png",
|
||||
"\\\\server\\share\\chart.png",
|
||||
"file://server/share/chart.png",
|
||||
"data:image/png;base64,AA==",
|
||||
"blob:https://example.com/image",
|
||||
"javascript:alert(1)",
|
||||
"custom:image.png",
|
||||
"C:chart.png",
|
||||
"chart%00.png",
|
||||
"chart%ZZ.png",
|
||||
"",
|
||||
])("does not read non-local or invalid source %s", (source) => {
|
||||
expect(localImagePath(source)).toBeUndefined()
|
||||
})
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { ReadMarkdownImage } from "../context/markdown"
|
||||
|
||||
export function localImagePath(source: string) {
|
||||
const value = source.trim().replaceAll("\\", "/")
|
||||
if (!value || /[\u0000-\u001f\u007f]/.test(value) || value.startsWith("//")) return
|
||||
if (/^file:/i.test(value)) {
|
||||
if (!URL.canParse(value)) return
|
||||
const url = new URL(value)
|
||||
if (url.hostname && url.hostname !== "localhost") return
|
||||
return decodePath(url.pathname.replace(/^\/([a-z]:\/)/i, "$1"))
|
||||
}
|
||||
if (/^[a-z][a-z\d+.-]*:/i.test(value) && !/^[a-z]:\//i.test(value)) return
|
||||
return decodePath(value)
|
||||
}
|
||||
|
||||
function decodePath(value: string) {
|
||||
try {
|
||||
const path = decodeURIComponent(value)
|
||||
if (/[\u0000-\u001f\u007f]/.test(path) || path.startsWith("//") || path.startsWith("\\\\")) return
|
||||
return path
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
export function createMarkdownImages(read: ReadMarkdownImage) {
|
||||
const entries = new Map<string, { controller: AbortController; result: Promise<string | undefined>; url?: string }>()
|
||||
return {
|
||||
update(root: HTMLElement) {
|
||||
const images = Array.from(root.querySelectorAll<HTMLImageElement>("img[data-local-image]"))
|
||||
const paths = new Set(images.map((image) => image.dataset.localImage))
|
||||
entries.forEach((entry, path) => {
|
||||
if (paths.has(path)) return
|
||||
entry.controller.abort()
|
||||
if (entry.url) URL.revokeObjectURL(entry.url)
|
||||
entries.delete(path)
|
||||
})
|
||||
images.forEach((image) => {
|
||||
const path = image.dataset.localImage
|
||||
if (!path) return
|
||||
const existing = entries.get(path)
|
||||
const entry = existing ?? {
|
||||
controller: new AbortController(),
|
||||
result: Promise.resolve<string | undefined>(undefined),
|
||||
url: undefined as string | undefined,
|
||||
}
|
||||
if (!existing) {
|
||||
entries.set(path, entry)
|
||||
entry.result = read(path, entry.controller.signal)
|
||||
.then(async (blob) => {
|
||||
if (!blob || entry.controller.signal.aborted) return
|
||||
// SVG documents must not inherit the app origin if opened outside the image element.
|
||||
if (blob.type === "image/svg+xml")
|
||||
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(await blob.text())}`
|
||||
entry.url = URL.createObjectURL(blob)
|
||||
return entry.url
|
||||
})
|
||||
.catch(() => undefined)
|
||||
}
|
||||
void entry.result.then((url) => {
|
||||
if (!url || entry.controller.signal.aborted || !root.contains(image) || image.dataset.localImage !== path)
|
||||
return
|
||||
image.src = url
|
||||
})
|
||||
})
|
||||
},
|
||||
dispose() {
|
||||
entries.forEach((entry) => {
|
||||
entry.controller.abort()
|
||||
if (entry.url) URL.revokeObjectURL(entry.url)
|
||||
})
|
||||
entries.clear()
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,8 @@ import { getCachedMarkdown, sanitizeMarkdown, touchCachedMarkdown, type Markdown
|
||||
import { inlineCodeKind } from "./markdown-inline-code-kind"
|
||||
import { renderMermaidSvg } from "./markdown-mermaid"
|
||||
import { createMarkdownRenderer } from "./markdown-solid"
|
||||
import { useMarkdown, type ReadMarkdownImage } from "../context/markdown"
|
||||
import { createMarkdownImages } from "./markdown-image"
|
||||
|
||||
type RenderedBlock =
|
||||
| (MarkdownCacheEntry & { key: string; mode: Exclude<Block["mode"], "code"> })
|
||||
@@ -385,6 +387,7 @@ export function Markdown(
|
||||
) {
|
||||
const [local, others] = splitProps(props, ["text", "cacheKey", "streaming", "deferUntilReady", "class", "classList"])
|
||||
const i18n = useI18n()
|
||||
const markdown = useMarkdown()
|
||||
const [root, setRoot] = createSignal<HTMLDivElement>()
|
||||
const owner = createUniqueId()
|
||||
const activeCodeKeys = new Set<string>()
|
||||
@@ -507,6 +510,8 @@ export function Markdown(
|
||||
)
|
||||
|
||||
let copyCleanup: (() => void) | undefined
|
||||
let readImage: ReadMarkdownImage | undefined
|
||||
let images: ReturnType<typeof createMarkdownImages> | undefined
|
||||
|
||||
createEffect(() => {
|
||||
const container = root()
|
||||
@@ -515,11 +520,17 @@ export function Markdown(
|
||||
const content = local.text ? pendingBlocks(result, projected, local.cacheKey, owner, local.deferUntilReady) : []
|
||||
if (!container) return
|
||||
if (isServer) return
|
||||
if (readImage !== markdown?.readImage) {
|
||||
images?.dispose()
|
||||
readImage = markdown?.readImage
|
||||
images = readImage ? createMarkdownImages(readImage) : undefined
|
||||
}
|
||||
delete container.dataset.markdownReady
|
||||
if (content.length === 0) {
|
||||
disposeCopyButtons(container)
|
||||
disposeRenderedMarkdown(container)
|
||||
container.innerHTML = ""
|
||||
images?.update(container)
|
||||
if (result?.ready && result.text === local.text) container.dataset.markdownReady = ""
|
||||
return
|
||||
}
|
||||
@@ -542,6 +553,7 @@ export function Markdown(
|
||||
disposeRenderedMarkdown(child)
|
||||
child.remove()
|
||||
}
|
||||
images?.update(container)
|
||||
container
|
||||
.querySelectorAll<HTMLElement>('[data-slot="markdown-copy-button"]')
|
||||
.forEach((button) => setCopyState(button, labels, button.dataset.copied === "true"))
|
||||
@@ -554,6 +566,7 @@ export function Markdown(
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
images?.dispose()
|
||||
if (copyCleanup) copyCleanup()
|
||||
const container = root()
|
||||
if (container) disposeRenderedMarkdown(container)
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { createContext, useContext, type ParentProps } from "solid-js"
|
||||
|
||||
export type ReadMarkdownImage = (path: string, signal: AbortSignal) => Promise<Blob | undefined>
|
||||
|
||||
const context = createContext<{ readonly readImage: ReadMarkdownImage }>()
|
||||
|
||||
export function MarkdownProvider(props: ParentProps<{ readImage: ReadMarkdownImage }>) {
|
||||
return (
|
||||
<context.Provider
|
||||
value={{
|
||||
get readImage() {
|
||||
return props.readImage
|
||||
},
|
||||
}}
|
||||
>
|
||||
{props.children}
|
||||
</context.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export const useMarkdown = () => useContext(context)
|
||||
Reference in New Issue
Block a user