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 = `${escapeHtml(filePath)}`; + 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);