mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
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:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user