fix: file viewer opens audio/svg/binary like the attachments viewer

The File Browser preview and Attachments preview share openFilePreview(),
but the workspace branch (via /file-content) misclassified several types the
attachments viewer handled fine:

- SVG was reported as type:image, but file-raw serves SVG as octet-stream +
  attachment (XSS hardening), so the <img> broke. Now fetched and rendered via
  a same-origin image/svg+xml blob <img> (safe; <img> never runs SVG scripts).
  file-raw's SVG hardening is unchanged.
- Audio (mp3/wav/ogg/m4a/aac/flac/opus) was type:binary -> "Cannot preview".
  Now classified as audio and rendered with <audio controls>; file-raw gained
  the matching audio/video MIME types so playback works.
- Binary formats not in the hardcoded list (xlsx/doc/zip/...) were decoded as
  UTF-8 and dumped as mojibake. Replaced the static list with a NUL-byte
  content sniff that flags arbitrary binaries; the binary fallback now offers a
  Download link instead of dead-ending.

Adds route tests for audio, known-binary (xlsx), and NUL-sniff classification.
Verified end-to-end on an isolated instance + headless browser.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-06-15 00:42:12 +02:00
parent dc63d1f1a6
commit b7ff54b2ec
4 changed files with 162 additions and 25 deletions
+24 -1
View File
@@ -2503,6 +2503,25 @@ Object.assign(CodemanApp.prototype, {
bodyEl.innerHTML = `<iframe src="${escapeHtml(rawSrc)}" title="${escapeHtml(filePath)}"></iframe>`;
return;
}
// SVG renders as an image, but file-raw deliberately serves SVG as an
// untrusted octet-stream attachment (XSS hardening), so a direct
// <img src=file-raw> would break. Fetch the bytes and render via a
// same-origin blob typed image/svg+xml — <img> never executes scripts in
// the referenced SVG, so this is safe while still rendering the graphic.
if (ext === 'svg') {
footerEl.textContent = 'SVG';
try {
const res = await fetch(`/api/sessions/${sessionId}/file-raw?path=${encodeURIComponent(filePath)}`);
if (!res.ok) throw new Error('Failed to load image');
const blobUrl = URL.createObjectURL(new Blob([await res.text()], { type: 'image/svg+xml' }));
bodyEl.innerHTML = `<img src="${blobUrl}" alt="${escapeHtml(filePath)}">`;
const img = bodyEl.querySelector('img');
if (img) img.onload = () => URL.revokeObjectURL(blobUrl);
} catch (err) {
bodyEl.innerHTML = `<div class="binary-message">Error: ${escapeHtml(err.message)}</div>`;
}
return;
}
try {
const res = await fetch(`/api/sessions/${sessionId}/file-content?path=${encodeURIComponent(filePath)}&lines=500`);
@@ -2519,8 +2538,12 @@ Object.assign(CodemanApp.prototype, {
} else if (data.type === 'video') {
bodyEl.innerHTML = `<video src="${data.url}" controls autoplay></video>`;
footerEl.textContent = `${this.formatFileSize(data.size)} \u2022 ${data.extension}`;
} else if (data.type === 'audio') {
bodyEl.innerHTML = `<audio src="${data.url}" controls autoplay></audio>`;
footerEl.textContent = `${this.formatFileSize(data.size)} \u2022 ${data.extension}`;
} else if (data.type === 'binary') {
bodyEl.innerHTML = `<div class="binary-message">Binary file (${this.formatFileSize(data.size)})<br>Cannot preview</div>`;
const downloadHref = `/api/sessions/${sessionId}/file-raw?path=${encodeURIComponent(filePath)}&download=true`;
bodyEl.innerHTML = `<div class="binary-message">Binary file (${this.formatFileSize(data.size)})<br>Cannot preview<br><a href="${escapeHtml(downloadHref)}" download>Download</a></div>`;
footerEl.textContent = data.extension || 'binary';
} else {
// Text content
+10
View File
@@ -7239,12 +7239,22 @@ kbd {
max-height: 100%;
}
.file-preview-body audio {
display: block;
width: 90%;
margin: 2rem auto;
}
.file-preview-body .binary-message {
padding: 2rem;
text-align: center;
color: var(--text-muted);
}
.file-preview-body .binary-message a {
color: var(--accent, #4ea1ff);
}
.file-preview-footer {
padding: 0.35rem 0.75rem;
font-size: 0.7rem;