diff --git a/README.md b/README.md index 73e80e1..3256771 100644 --- a/README.md +++ b/README.md @@ -60,9 +60,35 @@ | Math | KaTeX inline / display math | | Code fences | 使用 Markstream `MarkdownCodeBlockNode` + `stream-markdown` + Shiki;未知语言回退为可见纯文本 | | Raw HTML | 转义为文本,不注入 DOM | -| Links and images | 仅允许安全的外部协议 | +| Links and images | 仅允许安全的外部协议;本地图片文件经同源 `/dsh-img` 路由嵌入(见下) | | Plan review / trajectory 等静态 surface | 继续使用 Harness 内置 `MarkdownText`;这些 surface 没有统一替换 slot | +## 本地图片嵌入 + +Assistant Markdown 中的图片引用可以直接写**本机文件路径**,无需额外起一个静态文件服务器: + +```md +![截图](/home/thn/dsh/shots/result.png) +![图](file:///tmp/out/chart.webp) +![图](~/pics/a.jpg) +``` + +客户端把本地路径改写成同源路由 `/dsh-img?p=<绝对路径>`,宿主侧在 dsh web 服务器上注册了同名 GET 路由直接流式返回文件。浏览器按页面 origin 解析该 URL,因此 agent 不需要知道端口或域名;`http(s)` 远程图片行为不变。 + +**接受的路径形式**:POSIX 绝对路径、Windows 盘符/UNC 路径(斜杠与反斜杠两种写法)、`~/` 家目录相对路径,以及显式 `file://` URL(解析前会被规范化为绝对路径——上游 sanitizer 会丢弃非 http(s) scheme 的图片目的地;裸路径必须带图片扩展名 `.png .jpg .jpeg .gif .webp .avif .bmp .svg`,避免误吞真正的根相对 web 地址)。路径中的百分号转义(如空格 `%20`)在路由前只解码一次,因此带编码的文件名能解析到真实文件;非法转义则回退为 alt 文本。 + +**安全边界**:默认只允许已注册 workspace 目录下的文件(经 `realpath` 规范化后判断),另有扩展名白名单 + 文件头签名校验、20 MiB 体积上限。路由与 GUI 同源(web 服务器默认绑定回环地址);如需放宽,可在 profile 补丁层覆盖配置: + +```yaml +# == dsh-better-markdown +- id: better-markdown + config: + # extraRoots: [/tmp, /home/thn/pics] # 额外允许的目录 + # allowAny: true # 任意可读文件(最宽松,适合纯本地环境) + # maxBytes: 52428800 # 体积上限,默认 20 MiB + # extensions: [.png, .jpg] # 提供的扩展名列表(空列表 = 不提供任何文件) +``` + ## 工作原理 插件使用 Harness 公开的 client module 与 slot shadowing,不修改 Harness 源码,也不替换全局 React。 diff --git a/README_EN.md b/README_EN.md index 98cd32c..d29a9aa 100644 --- a/README_EN.md +++ b/README_EN.md @@ -60,9 +60,35 @@ | Math | KaTeX inline and display math | | Code fences | Uses Markstream `MarkdownCodeBlockNode` + `stream-markdown` + Shiki; unknown languages fall back to visible plain text | | Raw HTML | Escaped as text instead of being injected into the DOM | -| Links and images | Restricted to safe external protocols | +| Links and images | Safe external protocols only; local image files are embedded through the same-origin `/dsh-img` route (below) | | Static plan review / trajectory surfaces | Keep Harness `MarkdownText`; these surfaces expose no shared replacement slot | +## Local image embedding + +Image references in assistant Markdown may point straight at **local file paths**, with no separate static file server: + +```md +![screenshot](/home/thn/dsh/shots/result.png) +![chart](file:///tmp/out/chart.webp) +![pic](~/pics/a.jpg) +``` + +The client rewrites local destinations to the same-origin route `/dsh-img?p=`; the host registers a matching GET handler on the dsh web server that streams the file. The browser resolves the URL against the page origin, so agents never need to know the port or hostname; `http(s)` remote images are unchanged. + +**Accepted forms**: absolute POSIX paths, Windows drive/UNC paths (both slash and backslash spellings), and `~/` home-relative paths — bare paths must end in an image extension (`.png .jpg .jpeg .gif .webp .avif .bmp .svg`) so genuine root-relative web URLs are not swallowed; explicit `file://` destinations are normalized to absolute paths before parsing (the upstream sanitizer drops non-http(s) image schemes). Percent escapes in a destination (`%20`, …) are decoded exactly once before routing, so encoded file names resolve to real files; a malformed escape falls back to alt text. + +**Security boundary**: by default only files under registered workspace directories are served (checked after `realpath` normalization), plus an extension allowlist, a file-header signature check, and a 20 MiB size cap. The route is same-origin with the GUI (the web server binds loopback by default); widen it from a profile patch layer if needed: + +```yaml +# == dsh-better-markdown +- id: better-markdown + config: + # extraRoots: [/tmp, /home/thn/pics] # additional allowed directories + # allowAny: true # any readable file (most permissive; pure-local setups) + # maxBytes: 52428800 # size cap, default 20 MiB + # extensions: [.png, .jpg] # served extension list (empty = serve nothing) +``` + ## How it works The plugin uses the public Harness client-module and slot-shadowing APIs. It does not patch Harness files or replace React globally. diff --git a/package.json b/package.json index 4907fc9..ad21999 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "dsh-better-markdown", - "version": "0.1.2", - "description": "DeepSeek Harness Web plugin that renders streamed assistant Markdown with markstream-react.", + "version": "0.2.0", + "description": "DeepSeek Harness Web plugin that renders streamed assistant Markdown with markstream-react and embeds local image files through a same-origin /dsh-img route.", "packageManager": "pnpm@10.33.3", "author": "duskzhen", "homepage": "https://github.com/zerob13/dsh-better-markdown#readme", @@ -90,7 +90,11 @@ "@deepseek-ai/dsh-client-ui-conversation": "0.1.0-rc.6", "@deepseek-ai/dsh-client-ui-primitives": "0.1.0-rc.6", "@deepseek-ai/dsh-client-ui-slots": "0.1.0-rc.6", + "@deepseek-ai/dsh-host-webserver": "^0.1.0-rc.6", + "@deepseek-ai/dsh-workspace": "^0.1.0-rc.6", + "@deepseek-ai/schemastery": "^3.18.1", "@testing-library/react": "^16.3.0", + "@types/node": "^24.0.0", "@types/react": "~18.3.1", "@types/react-dom": "~18.3.0", "jsdom": "^26.1.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8d47f6b..3b9c35d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -51,9 +51,21 @@ importers: '@deepseek-ai/dsh-client-ui-slots': specifier: 0.1.0-rc.6 version: 0.1.0-rc.6(@deepseek-ai/cordis@4.0.1)(@deepseek-ai/dsh-invariants@0.1.0-rc.6(@deepseek-ai/cordis@4.0.1)) + '@deepseek-ai/dsh-host-webserver': + specifier: ^0.1.0-rc.6 + version: 0.1.0-rc.6(@deepseek-ai/cordis@4.0.1)(@deepseek-ai/dsh-invariants@0.1.0-rc.6(@deepseek-ai/cordis@4.0.1)) + '@deepseek-ai/dsh-workspace': + specifier: ^0.1.0-rc.6 + version: 0.1.0-rc.6(1cc52dfbc7fc17aad1b6b9c3711f64fc) + '@deepseek-ai/schemastery': + specifier: ^3.18.1 + version: 3.18.1 '@testing-library/react': specifier: ^16.3.0 version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@types/node': + specifier: ^24.0.0 + version: 24.13.3 '@types/react': specifier: ~18.3.1 version: 18.3.31 @@ -80,7 +92,7 @@ importers: version: 5.9.3 vitest: specifier: ^3.2.4 - version: 3.2.7(@types/debug@4.1.13)(jsdom@26.1.0) + version: 3.2.7(@types/debug@4.1.13)(@types/node@24.13.3)(jsdom@26.1.0) packages: @@ -1339,6 +1351,9 @@ packages: '@types/ms@2.1.0': resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + '@types/node@24.13.3': + resolution: {integrity: sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==} + '@types/prop-types@15.7.15': resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==} @@ -2503,6 +2518,9 @@ packages: unconfig-core@7.5.0: resolution: {integrity: sha512-Su3FauozOGP44ZmKdHy2oE6LPjk51M/TRRjHv2HNCWiDvfvCoxC2lno6jevMA91MYAdCdwP05QnWdWpSbncX/w==} + undici-types@7.18.2: + resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} + unist-util-is@6.0.1: resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==} @@ -3901,6 +3919,10 @@ snapshots: '@types/ms@2.1.0': {} + '@types/node@24.13.3': + dependencies: + undici-types: 7.18.2 + '@types/prop-types@15.7.15': {} '@types/react-dom@18.3.7(@types/react@18.3.31)': @@ -3932,13 +3954,13 @@ snapshots: chai: 5.3.3 tinyrainbow: 2.0.0 - '@vitest/mocker@3.2.7(vite@7.3.6)': + '@vitest/mocker@3.2.7(vite@7.3.6(@types/node@24.13.3))': dependencies: '@vitest/spy': 3.2.7 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 7.3.6 + vite: 7.3.6(@types/node@24.13.3) '@vitest/pretty-format@3.2.7': dependencies: @@ -5249,6 +5271,8 @@ snapshots: '@quansync/fs': 1.0.0 quansync: 1.0.0 + undici-types@7.18.2: {} + unist-util-is@6.0.1: dependencies: '@types/unist': 3.0.3 @@ -5295,13 +5319,13 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 - vite-node@3.2.4: + vite-node@3.2.4(@types/node@24.13.3): dependencies: cac: 6.7.14 debug: 4.4.3 es-module-lexer: 1.7.0 pathe: 2.0.3 - vite: 7.3.6 + vite: 7.3.6(@types/node@24.13.3) transitivePeerDependencies: - '@types/node' - jiti @@ -5316,7 +5340,7 @@ snapshots: - tsx - yaml - vite@7.3.6: + vite@7.3.6(@types/node@24.13.3): dependencies: esbuild: 0.28.2 fdir: 6.5.0(picomatch@4.0.5) @@ -5325,13 +5349,14 @@ snapshots: rollup: 4.62.4 tinyglobby: 0.2.17 optionalDependencies: + '@types/node': 24.13.3 fsevents: 2.3.3 - vitest@3.2.7(@types/debug@4.1.13)(jsdom@26.1.0): + vitest@3.2.7(@types/debug@4.1.13)(@types/node@24.13.3)(jsdom@26.1.0): dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.7 - '@vitest/mocker': 3.2.7(vite@7.3.6) + '@vitest/mocker': 3.2.7(vite@7.3.6(@types/node@24.13.3)) '@vitest/pretty-format': 3.2.7 '@vitest/runner': 3.2.7 '@vitest/snapshot': 3.2.7 @@ -5349,11 +5374,12 @@ snapshots: tinyglobby: 0.2.17 tinypool: 1.1.1 tinyrainbow: 2.0.0 - vite: 7.3.6 - vite-node: 3.2.4 + vite: 7.3.6(@types/node@24.13.3) + vite-node: 3.2.4(@types/node@24.13.3) why-is-node-running: 2.3.0 optionalDependencies: '@types/debug': 4.1.13 + '@types/node': 24.13.3 jsdom: 26.1.0 transitivePeerDependencies: - jiti diff --git a/src/client/renderer.tsx b/src/client/renderer.tsx index 239a270..a1e0f1b 100644 --- a/src/client/renderer.tsx +++ b/src/client/renderer.tsx @@ -15,16 +15,19 @@ import { SHIKI_LANGUAGES } from './shiki.ts' const CUSTOM_COMPONENT_SCOPE = 'dsh-better-markdown' +/** Type guard for the file-mentions resolver passed through code-block props. */ function isFileMentions(value: unknown): value is MarkdownFileMentions { return typeof value === 'object' && value !== null && 'resolve' in value && typeof value.resolve === 'function' } +/** Read the file-mentions resolver out of one node's render context, if present. */ function rendererFileMentions(ctx: NodeComponentProps['ctx']): MarkdownFileMentions | undefined { const value = ctx?.codeBlockProps?.fileMentions return isFileMentions(value) ? value : undefined } +/** Keep only http(s)/mailto destinations; anything else (relative, data:, …) stays inert. */ function safeLink(url: string): string | undefined { try { const protocol = new URL(url).protocol @@ -34,14 +37,113 @@ function safeLink(url: string): string | undefined { } } +/** Return the destination when it is a plain http(s) URL, else undefined (local files). */ function remoteImage(url: string): string | undefined { const safe = safeLink(url) return safe?.startsWith('http:') || safe?.startsWith('https:') ? safe : undefined } -/** Preserve DSH's external-only Markdown image policy. */ +/** Image extensions bare local paths must end with (mirrors the host route default). */ +const LOCAL_IMAGE_EXT = /\.(png|jpe?g|gif|webp|avif|bmp|svg)$/i + +/** Host route endpoint for same-origin local image serving. */ +export const DSH_IMG_ROUTE = '/dsh-img' + +/** + * Resolve a Markdown image destination that points at a local file into the + * same-origin `/dsh-img` URL. Accepted forms: absolute POSIX paths, Windows + * drive/UNC paths (both slash and backslash spellings), and `~/` home-relative + * paths — bare paths must end with an image extension so genuine root-relative + * web URLs are not swallowed; explicit `file://` URLs carry intent and skip the + * extension gate. Percent escapes (`%20`, `%5C`, …) are decoded exactly once + * before matching, so encoded file names resolve to real filesystem paths; a + * malformed escape rejects the destination safely (alt-text fallback). + * @param url - The raw Markdown image destination. + * @returns The route URL to render, or undefined when the destination is not a local file. + */ +export function localImage(url: string): string | undefined { + const raw = url.trim() + if (raw.length === 0) return undefined + + // Decode percent escapes exactly once before branch matching: covers `file://` + // destinations with encoded names (%20), parser-encoded backslashes in UNC paths + // (%5C), and bare paths rewritten from file URLs. A malformed escape is not a + // plausible local path, so reject it instead of shipping a broken route URL. + let value: string + try { + value = decodeURIComponent(raw) + } catch { + return undefined + } + + let path: string | undefined + const fileMatch = /^file:\/\/(.+)$/i.exec(value) + if (fileMatch !== null) { + const captured = fileMatch[1] ?? '' + // Explicit scheme carries intent; no extension gate. 'file:///C:/x' → '/C:/x', + // and the leading slash before a drive letter is dropped for the host route. + // Only query/fragment markers disqualify: spaces may be decoded %20 now. + if (!/[?#]/.test(captured)) { + let p = captured + if (/^\/[A-Za-z]:[\\/]/.test(p)) p = p.slice(1) + path = p.length > 0 ? p : undefined + } + } else if (/^~\//.test(value)) { + path = LOCAL_IMAGE_EXT.test(value) ? value : undefined + } else if (/^[A-Za-z]:[\\/]/.test(value)) { + path = LOCAL_IMAGE_EXT.test(value) ? value : undefined + } else if (value.startsWith('/') && !value.startsWith('//')) { + path = LOCAL_IMAGE_EXT.test(value) ? value : undefined + } else if (/^\\{1,2}/.test(value) && value.indexOf('\\', 2) !== -1) { + // Backslash UNC share paths (e.g. \\server\share\pic.png). Markdown escape + // processing may collapse the leading pair to a single backslash on the way + // through the parser, so both spellings are accepted; at least one further + // segment separator keeps single odd filenames from matching. + path = LOCAL_IMAGE_EXT.test(value) ? value : undefined + } + + if (path === undefined || path.length === 0) return undefined + return `${DSH_IMG_ROUTE}?p=${encodeURIComponent(path)}` +} + +/** + * The upstream image-src sanitizer drops non-http(s) schemes while tokenizing, so a + * `![alt](file:///abs/x.png)` destination never survives to DshImageNode as an image + * node. Rewrite those destinations to bare absolute paths before parsing — localImage() + * routes them through `/dsh-img` either way. Only image syntax (`![](...)`) is touched; + * plain links keep their original behavior. The RFC 8089 authority component is preserved + * in meaning: an empty or `localhost` authority marks a local root, a drive-letter + * authority names a local volume, and a named host (e.g. `file://server/share/x.png`) + * becomes the backslash UNC spelling that localImage() and the host route accept. + */ +const FILE_IMAGE_DEST = /(!\[[^\]]*\]\()file:\/\/([^)\s]*)/gi + +function rewriteFileImageDests(text: string): string { + if (!text.includes('file://')) return text + return text.replace(FILE_IMAGE_DEST, (match, open: string, rest: string) => { + const slash = rest.indexOf('/') + // No path component after the authority — leave the original destination alone. + if (slash === -1) return match + const authority = rest.slice(0, slash) + const pathPart = rest.slice(slash) + + if (authority !== '' && !/^localhost$/i.test(authority)) { + // 'file://C:/x' names a local volume; keep the drive letter and its path. + if (/^[A-Za-z]:$/.test(authority)) return open + authority + pathPart + // A named host is a share: convert to the backslash UNC spelling localImage() expects. + return open + '\\\\' + authority + pathPart.split('/').join('\\') + } + + let dest = pathPart + // 'file:///C:/x' already carries its drive letter; drop the leading slash for it. + if (/^\/[A-Za-z]:[\\/]/.test(dest)) dest = dest.slice(1) + return open + (dest === '' ? '/' : dest) + }) +} + +/** Preserve DSH's external-only Markdown image policy; local files go through the same-origin `/dsh-img` route. */ export function DshImageNode({ node }: NodeComponentProps) { - const src = remoteImage(node.src) + const src = remoteImage(node.src) ?? localImage(node.src) if (src === undefined) return {node.alt} return {node.alt} } @@ -152,13 +254,14 @@ export const MarkstreamMarkdown = memo(function MarkstreamMarkdown({ text, strea fileMentions?: MarkdownFileMentions | undefined }) { const isDark = useDshIsDark() + const content = useMemo(() => rewriteFileImageDests(text), [text]) const codeBlockProps = useMemo(() => ({ fileMentions: streaming ? undefined : fileMentions, }), [fileMentions, streaming]) return (
{ + return ctx.webServer.register({ + kind: 'exact', + path: IMAGE_ROUTE_PATH, + handler: (req, res) => { + // The registry is a synchronous projection; no persistence reads per request. + void serveImage(req, res, resolved, ctx.workspaceRegistry.list().map(workspace => workspace.path)) + .catch(() => { if (!res.headersSent) res.writeHead(500); res.end('image route failed') }) + }, + }) + }, 'dsh-better-markdown: /dsh-img route') +} diff --git a/src/server/image-route.ts b/src/server/image-route.ts new file mode 100644 index 0000000..7cbef82 --- /dev/null +++ b/src/server/image-route.ts @@ -0,0 +1,259 @@ +/** + * Same-origin local image route: `GET /dsh-img?p=` serves one + * local file from the dsh web server itself, so assistant Markdown can embed + * workspace images without a separate static file server. The browser resolves + * the root-relative URL against the GUI origin; no CORS is involved. + */ + +import { createReadStream, promises as fsp, realpathSync, statSync } from 'node:fs' +import type { FileHandle } from 'node:fs/promises' +import type { Stats } from 'node:fs' +import type { IncomingMessage, ServerResponse } from 'node:http' +import { homedir, platform } from 'node:os' +import { extname, resolve, sep } from 'node:path' + +/** Image extensions the route serves by default (lowercase, leading dot). */ +export const DEFAULT_IMAGE_EXTENSIONS = [ + '.png', + '.jpg', + '.jpeg', + '.gif', + '.webp', + '.avif', + '.bmp', + '.svg', +] as const + +/** Content types for the served extensions. */ +const CONTENT_TYPES: Record = { + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.gif': 'image/gif', + '.webp': 'image/webp', + '.avif': 'image/avif', + '.bmp': 'image/bmp', + '.svg': 'image/svg+xml', +} + +/** Magic-byte signatures checked before the first extension-only trust. */ +const SIGNATURES: Array<[Buffer, number]> = [ + [Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), 0], // png + [Buffer.from([0xff, 0xd8, 0xff]), 0], // jpeg + [Buffer.from('GIF8'), 0], // gif + [Buffer.from('RIFF'), 0], // webp (RIFF container; subtype verified below) + [Buffer.from('BM'), 0], // bmp +] + +/** Route configuration with safe defaults. */ +export interface ImageRouteConfig { + /** Hard cap on served file size in bytes. */ + maxBytes: number + /** Additional directories allowed beyond the registered workspaces. */ + extraRoots: string[] + /** When true, any readable local file is served (loopback convenience). */ + allowAny: boolean + /** Extensions accepted by the route; empty serves nothing. */ + extensions: readonly string[] +} + +/** Rejection reasons with their HTTP status. */ +export type ImageRejectReason = 'bad-path' | 'not-found' | 'extension' | 'too-large' | 'signature' + +/** One resolved candidate for a request path. */ +export interface ResolvedImage { + /** Canonical absolute path after `~` expansion and realpath. */ + real: string + /** Lowercased extension of the requested basename (empty when none). */ + ext: string +} + +/** + * Expand a leading `~/` against the home directory; any other spelling is + * returned unchanged so the caller can validate its shape. + * @param raw - The raw `p` query value. + * @returns The expanded absolute-path candidate. + */ +export function expandHome(raw: string): string { + if (raw === '~') return homedir() + if (raw.startsWith('~/')) return resolve(homedir(), raw.slice(2)) + return raw +} + +/** + * Validate the spelling of one requested path for this platform. + * @param raw - The `~`-expanded request value. + * @returns true when the shape is a plausible absolute local file path. + */ +export function looksLikeLocalPath(raw: string): boolean { + if (raw.length === 0) return false + if (platform() === 'win32') { + // Drive-letter paths in either slash spelling, or UNC shares. Markdown escape + // processing may collapse a leading backslash pair to one, so accept both. + return /^[A-Za-z]:[\\/]/.test(raw) || /^\\{1,2}/.test(raw) + } + return raw.startsWith('/') +} + +/** + * Check the leading bytes of one file against known image signatures. + * Extension-only formats (svg/avif) pass unless a conflicting signature is + * present, so unknown-but-valid encodings are not rejected by mistake. + * @param head - The first 16 bytes of the file. + * @returns true when the bytes do not contradict an image format. + */ +export function matchesImageSignature(head: Buffer): boolean { + if (head.length === 0) return true + const isWebp = head.length >= 12 && head.subarray(8, 12).toString('ascii') === 'WEBP' + for (const [signature, offset] of SIGNATURES) { + if (offset + signature.length > head.length) continue + if (!head.subarray(offset, offset + signature.length).equals(signature)) continue + // RIFF matches only when the WEBP subtype is present. + if (signature[0] === 0x52 && !isWebp) return false + return true + } + return true +} + +/** + * Expand `~`, validate the spelling, and canonicalize one request path. + * @param raw - The raw query value. + * @returns The resolved candidate, or undefined when missing/malformed. + */ +export function resolveImage(raw: string): ResolvedImage | undefined { + const expanded = expandHome(raw) + if (!looksLikeLocalPath(expanded)) return undefined + let real: string + try { + real = realpathSync(expanded) + } catch (error) { + const code = (error as NodeJS.ErrnoException).code + if (code === 'ENOENT' || code === 'ENOTDIR') return undefined + // An untraversable parent directory is effectively a missing file for this client. + if (code === 'EACCES' || code === 'EPERM') return undefined + throw error + } + return { real, ext: extname(real).toLowerCase() } +} + +/** + * Whether one canonical path sits inside any of the allowed roots. + * @param real - The realpath of the requested file. + * @param roots - Canonical directory prefixes (already realpathed). + * @returns true when the file is under at least one root. + */ +export function underAnyRoot(real: string, roots: readonly string[]): boolean { + return roots.some(root => real === root || real.startsWith(root + sep)) +} + +/** + * Build the response headers for one resolved image. + * @param candidate - The validated candidate. + * @param stat - Its filesystem stats. + * @returns Header values including a size+mtime weak ETag. + */ +export function imageHeaders(candidate: ResolvedImage, stat: Stats): Record { + return { + 'Content-Type': CONTENT_TYPES[candidate.ext] ?? 'application/octet-stream', + 'Content-Length': String(stat.size), + ETag: `W/"${stat.mtimeMs.toString(16)}-${stat.size}"`, + // Files are frequently regenerated mid-session; always revalidate. + 'Cache-Control': 'no-cache', + 'Accept-Ranges': 'bytes', + } +} + +/** + * Serve one `/dsh-img?p=` request. + * @param req - The incoming HTTP request (method GET or HEAD). + * @param res - The response to own from here on. + * @param config - Validated route configuration. + * @param workspaceRoots - Canonical registered workspace directories. + */ +export async function serveImage( + req: IncomingMessage, + res: ServerResponse, + config: ImageRouteConfig, + workspaceRoots: readonly string[], +): Promise { + const fail = (status: number, message: string): void => { + if (!res.headersSent) res.writeHead(status, { 'Content-Type': 'text/plain; charset=utf-8' }) + res.end(message) + } + + if (req.method !== 'GET' && req.method !== 'HEAD') { + res.setHeader('Allow', 'GET, HEAD') + return fail(405, `method ${req.method} not allowed`) + } + + let url: URL + try { + url = new URL(req.url ?? '/', 'http://localhost') + } catch { + return fail(400, 'bad request target') + } + const raw = url.searchParams.get('p') + if (raw === null || raw.length === 0) return fail(400, "missing query parameter 'p'") + + const resolved = resolveImage(raw) + if (resolved === undefined) return fail(404, 'file not found or bad path') + + let stat: Stats + try { + stat = statSync(resolved.real) + } catch (error) { + const code = (error as NodeJS.ErrnoException).code + if (code === 'ENOENT') return fail(404, 'file not found') + // Permission loss between resolve and stat is a controlled rejection, not a crash. + if (code === 'EACCES' || code === 'EPERM') return fail(403, 'file is inaccessible') + throw error + } + if (!stat.isFile()) return fail(404, 'not a regular file') + if (stat.size > config.maxBytes) return fail(413, `larger than ${config.maxBytes} bytes`) + + // Canonical allowed roots: registered workspaces plus configured extras. + const roots: string[] = [] + for (const candidate of [...workspaceRoots, ...config.extraRoots]) { + try { + roots.push(realpathSync(candidate)) + } catch { + // A missing extra root simply allows nothing under it. + } + } + + if (!config.allowAny && !underAnyRoot(resolved.real, roots)) return fail(403, 'outside allowed directories') + // allowAny serves any readable file; otherwise the extension gate applies and an + // empty list denies every extension (serving nothing). + if (!config.allowAny && !config.extensions.includes(resolved.ext)) { + return fail(415, `extension '${resolved.ext || '(none)'}' not served`) + } + + let handle: FileHandle + try { + handle = await fsp.open(resolved.real, 'r') + } catch (error) { + const code = (error as NodeJS.ErrnoException).code + if (code === 'ENOENT') return fail(404, 'file not found') + // Mode bits can drop between stat and open; keep the failure controlled. + if (code === 'EACCES' || code === 'EPERM') return fail(403, 'file is inaccessible') + throw error + } + try { + const head = Buffer.alloc(Math.min(16, stat.size)) + await handle.read(head, 0, head.length, 0).catch(() => undefined) + if (!matchesImageSignature(head)) return fail(415, 'bytes do not match a known image signature') + + const headers = imageHeaders(resolved, stat) + const etag = headers['ETag'] ?? '' + const inm = req.headers['if-none-match'] + if (typeof inm === 'string' && inm.includes(etag)) res.writeHead(304, { ETag: etag }) + else res.writeHead(200, headers) + } finally { + await handle.close().catch(() => undefined) + } + + if (req.method === 'HEAD') { res.end(); return } + const stream = createReadStream(resolved.real) + stream.on('error', () => { if (!res.headersSent) fail(500, 'read failed'); else res.destroy() }) + stream.pipe(res) +} diff --git a/tests/image-route.spec.ts b/tests/image-route.spec.ts new file mode 100644 index 0000000..8ff220f --- /dev/null +++ b/tests/image-route.spec.ts @@ -0,0 +1,282 @@ +import { Writable } from 'node:stream' +import type { IncomingMessage, ServerResponse } from 'node:http' +import * as fs from 'node:fs' +import { promises as fsp, chmodSync, mkdtempSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' + +// Delegate to the real filesystem but expose statSync as a spy so permission +// failures can be triggered deterministically (ESM exports are not spiable). +vi.mock('node:fs', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, statSync: vi.fn(actual.statSync) } +}) + +import { Config } from '../src/index.ts' +import { + DEFAULT_IMAGE_EXTENSIONS, + expandHome, + imageHeaders, + looksLikeLocalPath, + matchesImageSignature, + resolveImage, + serveImage, +} from '../src/server/image-route.ts' +import type { ImageRouteConfig } from '../src/server/image-route.ts' + +/** One real 1x1 transparent PNG. */ +const PNG_BYTES = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==', + 'base64', +) + +const DEFAULT_CONFIG: ImageRouteConfig = { + maxBytes: 1024 * 1024, + extraRoots: [], + allowAny: false, + extensions: [...DEFAULT_IMAGE_EXTENSIONS], +} + +interface CapturedResponse extends Writable { + statusCode?: number | undefined + headers: Record + chunks: Buffer[] +} + +/** Minimal ServerResponse surface used by serveImage (structural mock). */ +interface MockServerResponse extends Writable { + statusCode?: number | undefined + headers: Record + chunks: Buffer[] + headersSent: boolean + setHeader(key: string, value: string): void + writeHead(status: number, maybeHeaders?: Record | string): void +} + +describe('Config', () => { + it('applies defaults when the loader passes no config', async () => { + expect(await Config['~standard'].validate(undefined)).toEqual({ + value: { + maxBytes: 20 * 1024 * 1024, + extraRoots: [], + allowAny: false, + extensions: [...DEFAULT_IMAGE_EXTENSIONS], + }, + }) + }) +}) + +function mockRequest(method: string, url: string): IncomingMessage { + return { method, url, headers: {} } as unknown as IncomingMessage +} + +async function request( + method: string, + url: string, + config: ImageRouteConfig = DEFAULT_CONFIG, + roots: readonly string[] = [], + headers: Record = {}, +): Promise { + const res: MockServerResponse = new Writable({ write(chunk, _enc, cb) { res.chunks.push(Buffer.from(chunk)); cb() } }) as unknown as MockServerResponse + res.headers = {} + res.statusCode = undefined + res.chunks = [] + res.headersSent = false + res.setHeader = (key: string, value: string) => { res.headers[key.toLowerCase()] = String(value) } + res.writeHead = (status: number, maybeHeaders?: Record | string) => { + res.statusCode = status + if (typeof maybeHeaders === 'object' && maybeHeaders !== null) { + for (const [key, value] of Object.entries(maybeHeaders)) res.headers[key.toLowerCase()] = String(value) + } + res.headersSent = true + } + const req = mockRequest(method, url) as IncomingMessage & { headers: Record } + req.headers = { ...headers } + await serveImage(req, res as unknown as ServerResponse, config, roots) + // The body stream finishes after serveImage returns; wait for drain. + const state = (res as unknown as { _writableState?: { ended?: boolean; closing?: boolean } })._writableState + if (!state?.ended && !state?.closing) { + await new Promise(resolvePromise => { res.once('finish', () => resolvePromise()) }) + } + return res +} + +describe('expandHome', () => { + it('expands ~/ and bare ~ against the home directory', () => { + const home = process.env.HOME ?? process.env.USERPROFILE ?? '' + expect(expandHome('~')).toBe(home) + expect(expandHome('~/x/y.png')).toBe(join(home, 'x/y.png')) + }) + + it('leaves other spellings unchanged', () => { + expect(expandHome('/abs/path.png')).toBe('/abs/path.png') + expect(expandHome('relative.png')).toBe('relative.png') + }) +}) + +describe('looksLikeLocalPath', () => { + it('accepts absolute POSIX paths and rejects relatives on posix', () => { + if (process.platform === 'win32') return + expect(looksLikeLocalPath('/a/b/c.png')).toBe(true) + expect(looksLikeLocalPath('relative.png')).toBe(false) + expect(looksLikeLocalPath('')).toBe(false) + }) + + it('accepts drive and UNC paths on win32', () => { + if (process.platform !== 'win32') return + expect(looksLikeLocalPath('C:\\a\\b.png')).toBe(true) + expect(looksLikeLocalPath('//server/share/b.png')).toBe(true) + // Markdown escape processing may collapse a leading backslash pair to one. + expect(looksLikeLocalPath('\\server\\share\\b.png')).toBe(true) + }) +}) + +describe('matchesImageSignature', () => { + it('accepts png and empty heads', () => { + expect(matchesImageSignature(PNG_BYTES.subarray(0, 16))).toBe(true) + expect(matchesImageSignature(Buffer.alloc(0))).toBe(true) + }) + + it('rejects RIFF containers that are not WEBP', () => { + const riff = Buffer.from('RIFF\x00\x00\x00\x00AVI ') + expect(matchesImageSignature(riff)).toBe(false) + }) +}) + +describe('resolveImage', () => { + it('resolves existing absolute paths and rejects missing ones', () => { + const dir = mkdtempSync(join(tmpdir(), 'bm-img-')) + const file = join(dir, 'a.png') + writeFileSync(file, PNG_BYTES) + expect(resolveImage(file)).toEqual({ real: file, ext: '.png' }) + if (process.platform !== 'win32') { + expect(resolveImage(join(dir, 'missing.png'))).toBeUndefined() + expect(resolveImage('relative.png')).toBeUndefined() + } + }) +}) + +describe('serveImage', () => { + let root: string + let outsideDir: string + let pngPath: string + let txtPath: string + + beforeAll(() => { + root = mkdtempSync(join(tmpdir(), 'bm-root-')) + outsideDir = mkdtempSync(join(tmpdir(), 'bm-outside-')) + pngPath = join(root, 'shot.png') + writeFileSync(pngPath, PNG_BYTES) + txtPath = join(root, 'notes.txt') + writeFileSync(txtPath, 'hello world\n') + }) + + it('serves an image inside the allowed root with correct headers', async () => { + const res = await request('GET', `/dsh-img?p=${encodeURIComponent(pngPath)}`, DEFAULT_CONFIG, [root]) + expect(res.statusCode).toBe(200) + expect(res.headers['content-type']).toBe('image/png') + expect(Buffer.concat(res.chunks).equals(PNG_BYTES)).toBe(true) + }) + + it('rejects paths outside the allowed roots with 403', async () => { + const outside = join(outsideDir, 'x.png') + writeFileSync(outside, PNG_BYTES) + const res = await request('GET', `/dsh-img?p=${encodeURIComponent(outside)}`, DEFAULT_CONFIG, [root]) + expect(res.statusCode).toBe(403) + }) + + it('rejects unserved extensions with 415 even for valid image bytes', async () => { + const res = await request('GET', `/dsh-img?p=${encodeURIComponent(txtPath)}`, DEFAULT_CONFIG, [root]) + expect(res.statusCode).toBe(415) + }) + + it('rejects missing p with 400 and unknown files with 404', async () => { + expect((await request('GET', '/dsh-img', DEFAULT_CONFIG, [root])).statusCode).toBe(400) + const missing = join(root, 'nope.png') + expect((await request('GET', `/dsh-img?p=${encodeURIComponent(missing)}`, DEFAULT_CONFIG, [root])).statusCode).toBe(404) + }) + + it('rejects oversize files with 413', async () => { + const big = join(root, 'big.png') + writeFileSync(big, Buffer.concat([PNG_BYTES, Buffer.alloc(2048)])) + const res = await request('GET', `/dsh-img?p=${encodeURIComponent(big)}`, { ...DEFAULT_CONFIG, maxBytes: 1024 }, [root]) + expect(res.statusCode).toBe(413) + }) + + it('honors If-None-Match with a 304 revalidation', async () => { + const first = await request('GET', `/dsh-img?p=${encodeURIComponent(pngPath)}`, DEFAULT_CONFIG, [root]) + const etag = first.headers['etag'] ?? '' + expect(etag.length).toBeGreaterThan(0) + const second = await request( + 'GET', + `/dsh-img?p=${encodeURIComponent(pngPath)}`, + DEFAULT_CONFIG, + [root], + { 'if-none-match': etag }, + ) + expect(second.statusCode).toBe(304) + }) + + it('serves any readable file when allowAny is set', async () => { + const res = await request('GET', `/dsh-img?p=${encodeURIComponent(txtPath)}`, { ...DEFAULT_CONFIG, allowAny: true }, [root]) + expect(res.statusCode).toBe(200) + expect(Buffer.concat(res.chunks).toString()).toBe('hello world\n') + }) + + it('rejects non-GET methods with 405', async () => { + const res = await request('POST', `/dsh-img?p=${encodeURIComponent(pngPath)}`, DEFAULT_CONFIG, [root]) + expect(res.statusCode).toBe(405) + }) + + it('treats an empty extensions list as denying every file with 415', async () => { + const res = await request('GET', `/dsh-img?p=${encodeURIComponent(pngPath)}`, { ...DEFAULT_CONFIG, extensions: [] }, [root]) + expect(res.statusCode).toBe(415) + }) + + it('still serves any readable file when allowAny overrides an empty extension list', async () => { + const res = await request('GET', `/dsh-img?p=${encodeURIComponent(txtPath)}`, { ...DEFAULT_CONFIG, allowAny: true, extensions: [] }, [root]) + expect(res.statusCode).toBe(200) + expect(Buffer.concat(res.chunks).toString()).toBe('hello world\n') + }) + + it('maps a stat EACCES to a controlled 403 instead of escaping as a 500', async () => { + const error = Object.assign(new Error('EACCES: permission denied, stat'), { code: 'EACCES' }) + vi.mocked(fs.statSync).mockImplementationOnce(() => { throw error }) + try { + const res = await request('GET', `/dsh-img?p=${encodeURIComponent(pngPath)}`, DEFAULT_CONFIG, [root]) + expect(res.statusCode).toBe(403) + } finally { + vi.restoreAllMocks() + } + }) + + it('maps an unreadable file to a controlled 403 instead of escaping as a 500', async () => { + // Mode bits only bind non-root processes; root reads anything, so skip under root. + if (process.platform !== 'win32' && process.getuid?.() === 0) return + const locked = join(root, 'locked.png') + writeFileSync(locked, PNG_BYTES) + chmodSync(locked, 0o000) + try { + const res = await request('GET', `/dsh-img?p=${encodeURIComponent(locked)}`, DEFAULT_CONFIG, [root]) + expect(res.statusCode).toBe(403) + } finally { + chmodSync(locked, 0o644) + } + }) +}) + +describe('imageHeaders', () => { + it('emits a weak size+mtime ETag and no-cache policy', async () => { + const dir = mkdtempSync(join(tmpdir(), 'bm-etag-')) + const file = join(dir, 'a.png') + writeFileSync(file, PNG_BYTES) + const candidate = resolveImage(file) + expect(candidate).not.toBeUndefined() + const stat = await fsp.stat(file) + const headers = imageHeaders(candidate!, stat) + expect(headers['Cache-Control']).toBe('no-cache') + expect(headers.ETag).toMatch(/^W\/".+-\d+"$/) + expect(headers['Content-Type']).toBe('image/png') + }) +}) diff --git a/tests/plugin.spec.tsx b/tests/plugin.spec.tsx index c37fa76..2ff21a6 100644 --- a/tests/plugin.spec.tsx +++ b/tests/plugin.spec.tsx @@ -4,7 +4,7 @@ import { render, screen, waitFor } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' import { removeCustomComponents } from 'markstream-react' import { apply } from '../src/client/index.ts' -import { MarkstreamMarkdown } from '../src/client/renderer.tsx' +import { DSH_IMG_ROUTE, MarkstreamMarkdown, localImage } from '../src/client/renderer.tsx' function mountPlugin() { const register = vi.fn(() => () => {}) @@ -130,3 +130,108 @@ describe('browser plugin', () => { plugin.dispose() }) }) + +describe('local image embedding', () => { + it('resolves local path spellings to the same-origin /dsh-img route', () => { + expect(localImage('/home/thn/dsh/shot.png')).toBe(`${DSH_IMG_ROUTE}?p=${encodeURIComponent('/home/thn/dsh/shot.png')}`) + expect(localImage('file:///tmp/x/y.jpg')).toBe(`${DSH_IMG_ROUTE}?p=${encodeURIComponent('/tmp/x/y.jpg')}`) + expect(localImage('~/pics/a.webp')).toBe(`${DSH_IMG_ROUTE}?p=${encodeURIComponent('~/pics/a.webp')}`) + // Bare root-relative paths need an image extension; web assets stay untouched. + expect(localImage('/assets/app.js')).toBeUndefined() + expect(localImage('//cdn.example.com/a.png')).toBeUndefined() + expect(localImage('https://example.com/a.png')).toBeUndefined() + expect(localImage('data:image/png;base64,AAAA')).toBeUndefined() + }) + + it('decodes percent escapes exactly once and accepts backslash UNC paths', () => { + // Encoded names resolve to real filesystem paths (file URLs and rewritten bare forms). + expect(localImage('file:///tmp/shots/my%20shot.png')).toBe(`${DSH_IMG_ROUTE}?p=${encodeURIComponent('/tmp/shots/my shot.png')}`) + expect(localImage('/home/u/a%20b.png')).toBe(`${DSH_IMG_ROUTE}?p=${encodeURIComponent('/home/u/a b.png')}`) + // Parser-encoded backslashes in UNC paths decode to the pipeline spelling, + // where Markdown escape processing collapsed the leading pair to one backslash. + const uncCollapsed = '\\server\\share\\pic.png' + expect(localImage('%5Cserver%5Cshare%5Cpic.png')).toBe(`${DSH_IMG_ROUTE}?p=${encodeURIComponent(uncCollapsed)}`) + // Raw double-backslash UNC share paths are accepted directly as well. + const uncRaw = '\\\\server\\share\\pic.png' + expect(localImage(uncRaw)).toBe(`${DSH_IMG_ROUTE}?p=${encodeURIComponent(uncRaw)}`) + // Non-image UNC shares still fall through to alt text. + expect(localImage('\\\\server\\share\\notes.txt')).toBeUndefined() + // A malformed escape rejects the destination safely instead of shipping a broken route. + expect(localImage('/home/u/100%.png')).toBeUndefined() + }) + + it('renders file URLs whose paths contain percent-encoded spaces', () => { + const plugin = mountPlugin() + const view = render() + const img = view.container.querySelector('img.dsh-better-markdown__image') + expect(img).not.toBeNull() + expect(img?.getAttribute('src')).toBe(`${DSH_IMG_ROUTE}?p=${encodeURIComponent('/tmp/shots/my shot.png')}`) + plugin.dispose() + }) + + it('renders backslash UNC share paths as same-origin images', () => { + const plugin = mountPlugin() + const view = render() + const img = view.container.querySelector('img.dsh-better-markdown__image') + expect(img).not.toBeNull() + // The parser escape-collapses the leading pair and percent-encodes backslashes; + // localImage decodes that to the single-backslash spelling before routing. + expect(img?.getAttribute('src')).toBe(`${DSH_IMG_ROUTE}?p=${encodeURIComponent('\\server\\share\\pic.png')}`) + plugin.dispose() + }) + + it('rewrites file URLs with a localhost authority to local paths', () => { + const plugin = mountPlugin() + const view = render() + const img = view.container.querySelector('img.dsh-better-markdown__image') + expect(img).not.toBeNull() + // Per RFC 8089 a localhost authority marks the local root, so it routes like an empty one. + expect(img?.getAttribute('src')).toBe(`${DSH_IMG_ROUTE}?p=${encodeURIComponent('/tmp/out/chart.webp')}`) + plugin.dispose() + }) + + it('rewrites file URLs with a named authority to UNC share paths', () => { + const plugin = mountPlugin() + const view = render() + const img = view.container.querySelector('img.dsh-better-markdown__image') + expect(img).not.toBeNull() + // The rewrite emits the backslash UNC spelling; the parser collapse+encode round-trips + // through localImage to the single-backslash form, matching the raw UNC pipeline test. + expect(img?.getAttribute('src')).toBe(`${DSH_IMG_ROUTE}?p=${encodeURIComponent('\\server\\share\\pic.png')}`) + plugin.dispose() + }) + + it('renders absolute local paths as same-origin images', () => { + const plugin = mountPlugin() + const view = render() + const img = view.container.querySelector('img.dsh-better-markdown__image') + expect(img).not.toBeNull() + expect(img?.getAttribute('src')).toBe(`${DSH_IMG_ROUTE}?p=${encodeURIComponent('/home/thn/dsh/shot.png')}`) + plugin.dispose() + }) + + it('renders file:// URLs through the same-origin route', () => { + const plugin = mountPlugin() + const view = render() + const img = view.container.querySelector('img.dsh-better-markdown__image') + expect(img).not.toBeNull() + expect(img?.getAttribute('src')).toBe(`${DSH_IMG_ROUTE}?p=${encodeURIComponent('/home/thn/dsh/media/b.jpg')}`) + plugin.dispose() + }) + + it('keeps remote images on their original URL', () => { + const plugin = mountPlugin() + const view = render() + const img = view.container.querySelector('img.dsh-better-markdown__image') + expect(img?.getAttribute('src')).toBe('https://example.com/a.png') + plugin.dispose() + }) + + it('falls back to alt text for non-image local destinations', () => { + const plugin = mountPlugin() + const view = render() + expect(view.container.querySelector('img.dsh-better-markdown__image')).toBeNull() + expect(screen.getByText('script').closest('.dsh-better-markdown__image-alt')).not.toBeNull() + plugin.dispose() + }) +}) diff --git a/tsconfig.json b/tsconfig.json index 770833a..8d0720c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -13,7 +13,7 @@ "skipLibCheck": true, "declaration": true, "outDir": "lib/types", - "types": ["vitest/globals"] + "types": ["vitest/globals", "node"] }, "include": ["src", "tests"] }