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;
+84 -24
View File
@@ -511,49 +511,73 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
try {
const stat = await fs.stat(resolvedPath);
// Check if it's a binary/media file
// Classify by extension. Known media types render with a dedicated player;
// other known-binary types are flagged so the client offers a download
// affordance instead of trying to decode the bytes as text. Matches the
// breadth of formats the attachments viewer renders (image/audio/video/pdf)
// so the file viewer can open the same files.
const ext = filePath.split('.').pop()?.toLowerCase() || '';
const binaryExts = new Set([
'png',
'jpg',
'jpeg',
'gif',
'webp',
'ico',
'svg',
'bmp',
'mp4',
'webm',
'mov',
'avi',
'mp3',
'wav',
'ogg',
const imageExts = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp', 'ico']);
const videoExts = new Set(['mp4', 'webm', 'mov', 'm4v', 'ogv']);
const audioExts = new Set(['mp3', 'wav', 'ogg', 'oga', 'm4a', 'aac', 'flac', 'opus']);
const otherBinaryExts = new Set([
'pdf',
'zip',
'tar',
'gz',
'bz2',
'xz',
'7z',
'rar',
'exe',
'dll',
'so',
'dylib',
'bin',
'wasm',
'class',
'o',
'a',
'woff',
'woff2',
'ttf',
'eot',
'otf',
'xlsx',
'xls',
'doc',
'docx',
'ppt',
'pptx',
'odt',
'ods',
'odp',
'avi',
'mkv',
'wmv',
'flv',
]);
const imageExts = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp', 'ico']);
const videoExts = new Set(['mp4', 'webm', 'mov', 'avi']);
if (raw === 'true' || binaryExts.has(ext)) {
// Return metadata for binary files
const mediaType = imageExts.has(ext)
? 'image'
: videoExts.has(ext)
? 'video'
: audioExts.has(ext)
? 'audio'
: null;
const fileRawUrl = `/api/sessions/${id}/file-raw?path=${encodeURIComponent(filePath)}`;
if (raw === 'true' || mediaType || otherBinaryExts.has(ext)) {
// Return metadata for media/binary files (no text body)
return {
success: true,
data: {
path: filePath,
size: stat.size,
type: imageExts.has(ext) ? 'image' : videoExts.has(ext) ? 'video' : 'binary',
type: mediaType ?? 'binary',
extension: ext,
url: `/api/sessions/${id}/file-raw?path=${encodeURIComponent(filePath)}`,
url: fileRawUrl,
},
};
}
@@ -567,10 +591,39 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
);
}
// Read as raw bytes so we can sniff for binary content before decoding. An
// unrecognized extension (none at all, or a format not listed above) that
// is actually binary would otherwise be dumped to the viewer as UTF-8
// mojibake; a NUL byte in the first 8KB is a reliable binary signal that
// (unlike a static extension list) catches arbitrary binary formats.
const fileBuffer = await fs.readFile(resolvedPath);
const buf = Buffer.isBuffer(fileBuffer) ? fileBuffer : Buffer.from(String(fileBuffer));
const sniffLength = Math.min(buf.length, 8192);
let looksBinary = false;
for (let i = 0; i < sniffLength; i++) {
if (buf[i] === 0) {
looksBinary = true;
break;
}
}
if (looksBinary) {
return {
success: true,
data: {
path: filePath,
size: stat.size,
type: 'binary',
extension: ext,
url: fileRawUrl,
},
};
}
// Read text file with line limit (bounded to prevent DoS)
const MAX_LINES_LIMIT = 10000;
const maxLines = Math.min(parseInt(lines || '500', 10) || 500, MAX_LINES_LIMIT);
const content = await fs.readFile(resolvedPath, 'utf-8');
const content = buf.toString('utf-8');
const allLines = content.split('\n');
const truncatedContent = allLines.length > maxLines;
const displayContent = truncatedContent ? allLines.slice(0, maxLines).join('\n') : content;
@@ -638,9 +691,16 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
mp4: 'video/mp4',
webm: 'video/webm',
mov: 'video/quicktime',
m4v: 'video/mp4',
ogv: 'video/ogg',
mp3: 'audio/mpeg',
wav: 'audio/wav',
ogg: 'audio/ogg',
oga: 'audio/ogg',
opus: 'audio/ogg',
m4a: 'audio/mp4',
aac: 'audio/aac',
flac: 'audio/flac',
pdf: 'application/pdf',
json: 'application/json',
};
+44
View File
@@ -215,6 +215,50 @@ describe('file-routes', () => {
expect(body.data.url).toContain('file-raw');
});
it('returns audio metadata for audio files', async () => {
mockedStat.mockResolvedValue({ size: 2048 } as never);
const res = await harness.app.inject({
method: 'GET',
url: `/api/sessions/${harness.ctx._sessionId}/file-content?path=clip.mp3`,
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.success).toBe(true);
expect(body.data.type).toBe('audio');
expect(body.data.url).toContain('file-raw');
});
it('flags known-binary extensions (e.g. xlsx) instead of dumping mojibake', async () => {
mockedStat.mockResolvedValue({ size: 4096 } as never);
const res = await harness.app.inject({
method: 'GET',
url: `/api/sessions/${harness.ctx._sessionId}/file-content?path=sheet.xlsx`,
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.success).toBe(true);
expect(body.data.type).toBe('binary');
expect(body.data.content).toBeUndefined();
});
it('sniffs NUL bytes and flags binary content for unknown extensions', async () => {
const binary = Buffer.from([0x50, 0x4b, 0x03, 0x04, 0x00, 0x01, 0x02]);
mockedReadFile.mockResolvedValue(binary as never);
mockedStat.mockResolvedValue({ size: binary.length } as never);
const res = await harness.app.inject({
method: 'GET',
url: `/api/sessions/${harness.ctx._sessionId}/file-content?path=mystery.dat`,
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.success).toBe(true);
expect(body.data.type).toBe('binary');
expect(body.data.content).toBeUndefined();
});
it('rejects path traversal attempts', async () => {
// realpathSync resolves the symlink to a path outside workingDir
mockedRealpathSync.mockReturnValue('/etc/passwd' as never);