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
+1 -1
View File
@@ -290,7 +290,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
**File Viewer edit mode** (issue #212): the file-preview overlay edits workspace text files in place — `GET .../file-content?edit=1` + `PUT /api/sessions/:id/file-content`, policy in `src/config/file-editing.ts`. This is a **third file surface and the only one that WRITES**: read-path confinement (realpath + workspace + ownership) plus sensitive/blocked/`.git` denies and an extension **allowlist**; writes are `wx`-temp + rename (no `O_CREAT` anywhere = edit-in-place is structural); optimistic concurrency via sha256 `baseHash` → 409. ⚠️ `edit=1` never truncates and the client must never save a plain-preview buffer (the 500-line truncation would silently delete the rest). ⚠️ CRLF/UTF-8 guards: EOL re-applied server-side, non-UTF-8 refused via round-trip compare. → [architecture-invariants#file-viewer-edit-mode](docs/architecture-invariants.md#file-viewer-edit-mode), `docs/file-viewer-edit-plan.md`
**File Viewer text view: rendered markdown + Lines/Wrap toggles** (`_renderFilePreviewText()` in panels-ui.js): a `.md`/`.markdown` opens RENDERED by default with an `MD` pill back to source; the plain-text view has `Lines` (CSS-counter gutter) and `Wrap` toggles. ⚠️ ONE markdown pipeline: the viewer calls `_renderMarkdown()` (marked + the DOMPurify allowlist, the Response Viewer's) and binds the Response Viewer's click delegate (`_bindResponseViewerInteractions`) on the preview body for code-copy buttons and path links; never a second parser or handler. ⚠️ The document is built inside a `<template>` (a detached div with `innerHTML` set starts fetching every `<img src>` before the rewrite), then `_rebaseFilePreviewMarkdownRefs()` points relative images at the workspace-confined `file-raw` under the document's directory (never a widened route; a failed load degrades to alt text) and turns relative links into `a.rv-path` for the delegate, stripping the `target` marked gave them. ⚠️ The container carries `data-i18n-skip` or the translator rewrites the document's prose. ⚠️ Toggles are per-device localStorage keys (`codeman:filePreview*`), never `SettingsUpdateSchema`; Lines/Wrap are class flips on the ONE `<pre>`, with rules scoped `.file-preview-body > pre.file-preview-text` so they never leak into the document's code blocks. Markdown fetches `lines=10000` (the route ceiling), other text keeps 500. ⚠️ `md` stays OUT of `FILE_PREVIEW_EXTENSIONS`: a printed `.md` path keeps the tail viewer (live follow); the rendered view is the Files panel's. Tests: `test/file-preview-markdown.test.ts`. → [architecture-invariants#file-viewer-text-view-rendered-markdown-and-text-toggles](docs/architecture-invariants.md#file-viewer-text-view-rendered-markdown-and-text-toggles)
**File Viewer text view: rendered markdown + Lines/Wrap toggles** (`_renderFilePreviewText()` in panels-ui.js): a `.md`/`.markdown` opens RENDERED by default with an `MD` pill back to source; the plain-text view has `Lines` (CSS-counter gutter) and `Wrap` toggles. ⚠️ ONE markdown pipeline: the viewer calls `_renderMarkdown()` (marked + the DOMPurify allowlist, the Response Viewer's) and binds the Response Viewer's click delegate (`_bindResponseViewerInteractions`) on the preview body for code-copy buttons and path links; never a second parser or handler. ⚠️ The document is built inside a `<template>` (a detached div with `innerHTML` set starts fetching every `<img src>` before the rewrite), then `_rebaseFilePreviewMarkdownRefs()` points relative images at the workspace-confined `file-raw` under the document's directory and root-relative ones under the workspace root (never a widened route; a failed load degrades to alt text), after `decodeURIComponent`ing the ref and dropping `?query`/`#fragment` (marked percent-encodes destinations, and the route encodes again), and turns workspace links into `a.rv-path` carrying `data-session-id` for the delegate, stripping the `target` marked gave them. ⚠️ The container carries `data-i18n-skip` or the translator rewrites the document's prose. ⚠️ Toggles are per-device localStorage keys (`codeman:filePreview*`), never `SettingsUpdateSchema`; Lines/Wrap are class flips on the ONE `<pre>`, with rules scoped `.file-preview-body > pre.file-preview-text` so they never leak into the document's code blocks. Markdown fetches `lines=10000` (the route ceiling), other text keeps 500. ⚠️ `md` stays OUT of `FILE_PREVIEW_EXTENSIONS`: a printed `.md` path keeps the tail viewer (live follow); the rendered view is the Files panel's. Tests: `test/file-preview-markdown.test.ts`. → [architecture-invariants#file-viewer-text-view-rendered-markdown-and-text-toggles](docs/architecture-invariants.md#file-viewer-text-view-rendered-markdown-and-text-toggles)
**Files panel search** (COD-236, the `q` param on `GET /api/sessions/:id/files`): `compileFileQuery()` (`utils/file-query.ts`, pure) compiles the query into a predicate the server-side walk prunes with; a query returns a FLAT match list and the walk recurses past non-matching directories. An empty, whitespace-only or overlong (`MAX_QUERY_LENGTH`, 256) query compiles to `null`, keeping the default tree response byte-identical. ⚠️ **Never compile a glob into a RegExp** (`*a*a*a…` backtracks and freezes the event loop for the whole server): `globMatch()` is a two-pointer wildcard walk. → [architecture-invariants#files-panel-search](docs/architecture-invariants.md#files-panel-search)
+1 -1
View File
@@ -389,7 +389,7 @@ Tests: `test/file-editing-policy.test.ts` (pure policy), `test/routes/file-write
**Rendered markdown + Lines/Wrap** (`_renderFilePreviewText()` and its helpers in `panels-ui.js`, buttons in the `.file-preview-actions` row): a `.md`/`.markdown` opened in the File Viewer renders as a document by default, with an `MD` pill back to source; the plain-text view has `Lines` (a CSS-counter gutter) and `Wrap` toggles. Codeman already had `marked` + DOMPurify behind `_renderMarkdown()` for the Response Viewer, so the viewer reuses that and the codebase keeps ONE markdown pipeline.
- ⚠️ **One pipeline, one delegate.** The viewer calls `_renderMarkdown()` (marked + the `sanitize-html.js` allowlist) and binds `_bindResponseViewerInteractions()` on `#filePreviewBody` (container-bound and idempotent, so once per page) for the code-block copy buttons and `a.rv-path` opening. Never a second parser, never a second click handler for the same markup.
- ⚠️ **Build inside a `<template>`, then rebase.** A detached div whose `innerHTML` is set starts fetching every `<img src>` at once, so the document's relative image paths would hit the server as `/docs/img.png` 404s before being rewritten. `_rebaseFilePreviewMarkdownRefs()` runs on the template content: relative images go to the workspace-confined `file-raw` under the document's directory (the server refuses escapes, so `..` is forwarded as-is), and one `error` handler per image degrades it to alt text, which covers a remote image the page CSP blocks, a 404 for a document outside the workspace, and an SVG that `file-raw` serves as a download. Never widen a route for this. Relative links become `a.rv-path` with `data-path` and lose the `target`/`rel` that `_renderMarkdown` gives every link, which would otherwise open `<origin>/docs/x.md` in a new tab; fragment and http(s) links are untouched.
- ⚠️ **Build inside a `<template>`, then rebase.** A detached div whose `innerHTML` is set starts fetching every `<img src>` at once, so the document's relative image paths would hit the server as `/docs/img.png` 404s before being rewritten. `_rebaseFilePreviewMarkdownRefs()` runs on the template content: relative images go to the workspace-confined `file-raw` under the document's directory, root-relative ones (`/docs/x.png`) under the workspace root as on GitHub (the server refuses escapes, so `..` is forwarded as-is), and one `error` handler per image degrades it to alt text, which covers a remote image the page CSP blocks, a 404 for a document outside the workspace, and an SVG that `file-raw` serves as a download. Never widen a route for this. ⚠️ marked percent-encodes destinations (`my image.png` arrives as `my%20image.png`, CJK names as `%E5…`), so the ref is `decodeURIComponent`ed (a malformed escape is kept as written) and stripped of `#fragment` and `?query` BEFORE the route encodes it again; without that `file-raw` looks for a file literally named `my%20image.png`. Workspace links become `a.rv-path` with `data-path` AND `data-session-id` (the preview's session, which the `app.js` delegate prefers over `activeSessionId`, since a preview opened from another session's attachment card must resolve links against that workspace) and lose the `target`/`rel` that `_renderMarkdown` gives every link, which would otherwise open `<origin>/docs/x.md` in a new tab; fragment, protocol-relative and http(s) links are untouched.
- ⚠️ **`data-i18n-skip` on the container.** The translator's MutationObserver translates inserted headings and paragraphs, and the `.file-preview-content` entry in its skip list matches nothing (no element has that class), so the attribute is what keeps a Chinese UI from rewriting a README.
- ⚠️ **Toggles are per-device, in their own localStorage keys** (`codeman:filePreviewMdRendered` / `LineNumbers` / `Wrap`), for the same reason as the Files panel's show-hidden toggle: the app-settings object is rebuilt from the settings modal on every save, and they are display state, not synced settings (`SettingsUpdateSchema` is `.strict()`). MD re-renders from the kept source (`filePreviewContent`, which is also what Copy copies) without a refetch; Lines/Wrap are class flips on the one `<pre>`, whose rules are scoped `.file-preview-body > pre.file-preview-text` so they never leak into the document's own code blocks. Lines are one inline `<span class="fp-line">` per line joined by real newlines, the counter in `::before` with `user-select: none`, so select and copy return the exact text.
- ⚠️ **Caps.** Markdown fetches `lines=10000` (the route's `MAX_LINES_LIMIT`), because a rendered document cut at 500 lines reads as the whole document; other text keeps 500, which is what stops a huge log locking the tab in one `<pre>`. The attachment (out-of-workspace) branch keeps its 512 KB Range read and skips the 500-line clip for markdown. Edit mode is unchanged and still re-fetches `edit=1`; the three toggles hide while editing and for images, media and PDFs.
+1 -1
View File
@@ -14,7 +14,7 @@ It renders what it can:
| Kind | Behaviour |
| ------------------------ | ------------------------------------------------------------------------- |
| Text and code | Plain preview with Lines (line numbers) and Wrap toggles in the header. Long files are truncated in plain preview. |
| Markdown | Rendered by default: headings, tables, code blocks with copy buttons, images and links relative to the file. The MD pill in the header flips to source. |
| Markdown | Rendered by default: headings, tables, code blocks with copy buttons, images and links relative to the file (root-relative ones resolve from the workspace root, as on GitHub). The MD pill in the header flips to source. |
| Images | Inline. |
| Audio and video | Inline with a working scrub bar, because range requests are supported. |
| PDF and Office documents | Converted for preview when a converter is available. |
+3 -1
View File
@@ -2358,7 +2358,9 @@ class CodemanApp {
ev.preventDefault();
ev.stopPropagation();
const filePath = pathLink.dataset.path;
if (filePath) this.openFilePreview(filePath, this.activeSessionId);
// A rendered document's links name the session the preview was opened
// for (_rebaseFilePreviewMarkdownRefs), which need not be the active tab.
if (filePath) this.openFilePreview(filePath, pathLink.dataset.sessionId || this.activeSessionId);
return;
}
+3
View File
@@ -727,6 +727,9 @@
'Source type filter': '来源类型筛选',
'Copy content': '复制内容',
'Edit file': '编辑文件',
'Rendered markdown': '渲染 Markdown',
'Line numbers': '行号',
'Wrap lines': '自动换行',
'Unsaved changes': '未保存的更改',
Saved: '已保存',
'Export as JSON': '导出为 JSON',
+29 -14
View File
@@ -4357,28 +4357,42 @@ Object.assign(CodemanApp.prototype, {
},
/**
* Point a rendered document's relative references at the file it came from.
* Point a rendered document's workspace references at the file it came from.
*
* Images are rebased onto the workspace-confined file-raw route under the
* document's directory (the server refuses escapes, so `..` is safe to
* forward). Whatever fails to load degrades to its alt text with one error
* handler: a remote image the page CSP blocks, a 404 for a document outside
* the workspace, an SVG that file-raw serves as a download. Relative links
* document's directory, root-relative ones (`/docs/x.png`) under the
* workspace root as on GitHub (the server refuses escapes, so `..` is safe
* to forward). Whatever fails to load degrades to its alt text with one
* error handler: a remote image the page CSP blocks, a 404 for a document
* outside the workspace, an SVG that file-raw serves as a download. Links
* take the `a.rv-path` shape the Response Viewer delegate already opens in
* this overlay, minus the target/rel `_renderMarkdown` gave them, which
* would otherwise open <origin>/docs/x.md in a new tab.
* would otherwise open <origin>/docs/x.md in a new tab, and carry the
* preview's own session so a document opened from another session's
* attachment card resolves against that workspace, not the active tab's.
*/
_rebaseFilePreviewMarkdownRefs(root, { sessionId, filePath }) {
const dir = filePath.includes('/') ? filePath.slice(0, filePath.lastIndexOf('/') + 1) : '';
// Relative = no scheme, not root-relative (which includes //host), not a fragment.
const isRelative = (ref) => !!ref && !/^[a-z][a-z0-9+.-]*:/i.test(ref) && !ref.startsWith('/') && !ref.startsWith('#');
// GitHub-style `img.png#gh-dark-mode-only` and `doc.md#section`: the
// fragment is not part of the path. `.` and `..` segments are collapsed so
// the title reads `README.md`, not `docs/../README.md`; a `..` that climbs
// Workspace ref = no scheme, not protocol-relative (//host), not a fragment.
const isWorkspaceRef = (ref) =>
!!ref && !/^[a-z][a-z0-9+.-]*:/i.test(ref) && !ref.startsWith('//') && !ref.startsWith('#');
// GitHub-style `img.png#gh-dark-mode-only`, `doc.md#section` and
// `img.png?raw=true`: neither fragment nor query is part of the path.
// marked percent-encodes destinations (`my image.png` arrives as
// `my%20image.png`), so decode before the route encodes again, or file-raw
// looks for a file literally named `my%20image.png`; a malformed escape
// keeps the ref as written. `.` and `..` segments are collapsed so the
// title reads `README.md`, not `docs/../README.md`; a `..` that climbs
// past the start is kept and left for the server to refuse.
const resolveRef = (ref) => {
let rel = ref.split('#')[0].split('?')[0];
try {
rel = decodeURIComponent(rel);
} catch {
/* malformed escape: keep the ref as written */
}
const parts = [];
for (const seg of (dir + ref.split('#')[0]).split('/')) {
for (const seg of (rel.startsWith('/') ? rel.slice(1) : dir + rel).split('/')) {
if (seg === '.' || (seg === '' && parts.length)) continue;
if (seg === '..' && parts.length && parts[parts.length - 1] !== '..' && parts[parts.length - 1] !== '') parts.pop();
else parts.push(seg);
@@ -4387,7 +4401,7 @@ Object.assign(CodemanApp.prototype, {
};
for (const img of root.querySelectorAll('img[src]')) {
const src = img.getAttribute('src') || '';
if (isRelative(src)) {
if (isWorkspaceRef(src)) {
const path = resolveRef(src);
img.setAttribute('src', CodemanBase.url(`/api/sessions/${sessionId}/file-raw?path=${encodeURIComponent(path)}`));
}
@@ -4395,9 +4409,10 @@ Object.assign(CodemanApp.prototype, {
}
for (const a of root.querySelectorAll('a[href]')) {
const href = a.getAttribute('href') || '';
if (!isRelative(href)) continue;
if (!isWorkspaceRef(href)) continue;
a.className = 'rv-path';
a.dataset.path = resolveRef(href);
a.dataset.sessionId = sessionId;
a.setAttribute('href', '#');
a.removeAttribute('target');
a.removeAttribute('rel');
+4 -2
View File
@@ -84,7 +84,10 @@
/**
* Attributes allowed on the tags above. `style` is intentionally absent (CSS-based vectors).
* `class`/`id` survive because the response viewer adds wrapper classes downstream and code
* blocks may carry `language-*` classes from marked.
* blocks may carry `language-*` classes from marked. `name` is absent on purpose: marked never
* emits it, and `<img name="app">` would make `document.app` that image, which every inline
* `onclick="app.…()"` handler resolves before the global (DOM clobbering), so one rendered
* README could break every button until a reload.
*/
var ALLOWED_ATTR = [
'href',
@@ -93,7 +96,6 @@
'title',
'class',
'id',
'name',
'colspan',
'rowspan',
'align',
+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\)/);
});
});