diff --git a/src/web/public/panels-ui.js b/src/web/public/panels-ui.js
index 3557c240..5c78bc03 100644
--- a/src/web/public/panels-ui.js
+++ b/src/web/public/panels-ui.js
@@ -2503,6 +2503,25 @@ Object.assign(CodemanApp.prototype, {
bodyEl.innerHTML = ``;
return;
}
+ // SVG renders as an image, but file-raw deliberately serves SVG as an
+ // untrusted octet-stream attachment (XSS hardening), so a direct
+ //
would break. Fetch the bytes and render via a
+ // same-origin blob typed image/svg+xml —
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 = `
`;
+ const img = bodyEl.querySelector('img');
+ if (img) img.onload = () => URL.revokeObjectURL(blobUrl);
+ } catch (err) {
+ bodyEl.innerHTML = `
Error: ${escapeHtml(err.message)}
`;
+ }
+ 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 = ``;
footerEl.textContent = `${this.formatFileSize(data.size)} \u2022 ${data.extension}`;
+ } else if (data.type === 'audio') {
+ bodyEl.innerHTML = ``;
+ footerEl.textContent = `${this.formatFileSize(data.size)} \u2022 ${data.extension}`;
} else if (data.type === 'binary') {
- bodyEl.innerHTML = `Binary file (${this.formatFileSize(data.size)})
Cannot preview
`;
+ const downloadHref = `/api/sessions/${sessionId}/file-raw?path=${encodeURIComponent(filePath)}&download=true`;
+ bodyEl.innerHTML = `Binary file (${this.formatFileSize(data.size)})
Cannot preview
Download `;
footerEl.textContent = data.extension || 'binary';
} else {
// Text content
diff --git a/src/web/public/styles.css b/src/web/public/styles.css
index ba3f1622..4b4ecf98 100644
--- a/src/web/public/styles.css
+++ b/src/web/public/styles.css
@@ -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;
diff --git a/src/web/routes/file-routes.ts b/src/web/routes/file-routes.ts
index 4358fc35..6d4da5fb 100644
--- a/src/web/routes/file-routes.ts
+++ b/src/web/routes/file-routes.ts
@@ -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',
};
diff --git a/test/routes/file-routes.test.ts b/test/routes/file-routes.test.ts
index 3bc1dc8a..bec8ab04 100644
--- a/test/routes/file-routes.test.ts
+++ b/test/routes/file-routes.test.ts
@@ -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);