fix(files): decode markdown refs, scope links to the preview session, drop name= from the sanitizer

Review follow-up on #503. marked percent-encodes link and image destinations, and the rebase pass encoded them a second time, so a space or a CJK character in a file name made file-raw look for a file literally named my%20image.png; refs are now decoded once (a malformed escape is kept as written) and stripped of ?query along with #fragment. Root-relative refs resolve from the workspace root as on GitHub instead of falling through as Codeman URLs. Rebased links carry the preview's own session id and the response-viewer delegate prefers it, so a document opened from another session's attachment card opens its links in that workspace rather than the active tab's.

The sanitizer no longer allows name=: marked never emits it, and <img name="app"> made document.app that image, which every inline onclick="app.…()" handler resolves before the global, so one rendered README broke every viewer button until a reload. Adds the zh-CN strings for the three toolbar titles.
This commit is contained in:
JD
2026-09-28 15:22:37 -04:00
parent 5e27043bf7
commit 612c69d57a
10 changed files with 88 additions and 22 deletions
+35 -1
View File
@@ -50,7 +50,15 @@ const MARKDOWN_HTML =
'<a href="guide/x.md#sec" target="_blank" rel="noopener noreferrer">x</a>' +
'<a href="../CHANGELOG.md" target="_blank" rel="noopener noreferrer">up</a>' +
'<a href="#top">t</a>' +
'<a href="https://e.com" target="_blank" rel="noopener noreferrer">e</a>';
'<a href="https://e.com" target="_blank" rel="noopener noreferrer">e</a>' +
// marked percent-encodes destinations; a query rides along on GitHub-style refs.
'<img src="my%20image.png" alt="space">' +
'<img src="raw.png?raw=true" alt="raw">' +
'<img src="bad%zz.png" alt="bad">' +
'<img src="/assets/root.png" alt="root">' +
'<img src="//cdn.example.com/p.png" alt="protorel">' +
'<a href="%E5%9B%BE%E7%89%87/%E6%88%AA%E5%9B%BE.md" target="_blank" rel="noopener noreferrer">cjk</a>' +
'<a href="/docs/root.md" target="_blank" rel="noopener noreferrer">rootlink</a>';
const MD_CONTENT = '# Title\n\nx\n';
const TXT_CONTENT = 'one\n\n three\tfour\n';
@@ -200,6 +208,32 @@ describe('file viewer rendered markdown', () => {
expect(external.getAttribute('target')).toBe('_blank');
});
it('decodes percent-encoded refs, drops the query, and resolves root-relative refs against the workspace', async () => {
const { app, body } = loadApp();
await app.openFilePreview('docs/README.md', 's1');
const src = (alt: string) => body.querySelector(`img[alt="${alt}"]`)!.getAttribute('src');
const raw = (path: string) => `/api/sessions/s1/file-raw?path=${encodeURIComponent(path)}`;
// Decoded once here, encoded once for the route: never `my%2520image.png`.
expect(src('space')).toBe(raw('docs/my image.png'));
expect(src('raw')).toBe(raw('docs/raw.png'));
// A malformed escape keeps the ref as written.
expect(src('bad')).toBe(raw('docs/bad%zz.png'));
// Root-relative is the workspace root, as on GitHub; protocol-relative is remote.
expect(src('root')).toBe(raw('assets/root.png'));
expect(src('protorel')).toBe('//cdn.example.com/p.png');
const anchors = Array.from(body.querySelectorAll('a'));
expect(anchors.find((a) => a.textContent === 'cjk')!.getAttribute('data-path')).toBe('docs/图片/截图.md');
expect(anchors.find((a) => a.textContent === 'rootlink')!.getAttribute('data-path')).toBe('docs/root.md');
// Every rebased link names the preview's session, so the delegate opens it
// in that workspace even when another tab is active.
const rebased = body.querySelectorAll('a.rv-path');
expect(rebased.length).toBe(4);
for (const a of rebased) expect(a.getAttribute('data-session-id')).toBe('s1');
});
it('degrades an image that fails to load to its alt text', async () => {
const { app, body } = loadApp();
+10
View File
@@ -165,6 +165,16 @@ describe('COD-56 markdown sanitizer (DOMPurify allowlist)', () => {
expect(sanitize(html).toLowerCase()).not.toContain(tag);
});
}
// DOM clobbering: <img name="app"> makes document.app that image, and inline
// onclick="app.…()" handlers resolve `app` on the document before the global,
// so a rendered README could break every button until a reload.
it('drops name= (marked never emits it; it clobbers document.<name>)', () => {
const out = sanitize('<img name="app" src="https://example.com/x.png" alt="x"><a name="app" href="#a">a</a>');
expect(out).not.toMatch(/\sname\s*=/i);
expect(out).toContain('src="https://example.com/x.png"');
expect(out).toContain('href="#a"');
});
});
describe('legitimate markdown-rendered HTML survives', () => {
+1 -1
View File
@@ -145,6 +145,6 @@ describe('response viewer file-path linkifier', () => {
// either leaves inert paths (no linkify) or dead links (no handler).
expect(APP_SOURCE).toContain('this._linkifyFilePaths(renderedText)');
expect(APP_SOURCE).toMatch(/closest\('a\.rv-path'\)/);
expect(APP_SOURCE).toMatch(/openFilePreview\(filePath, this\.activeSessionId\)/);
expect(APP_SOURCE).toMatch(/openFilePreview\(filePath, pathLink\.dataset\.sessionId \|\| this\.activeSessionId\)/);
});
});