mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-09 00:49:41 +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>`;
|
bodyEl.innerHTML = `<iframe src="${escapeHtml(rawSrc)}" title="${escapeHtml(filePath)}"></iframe>`;
|
||||||
return;
|
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 {
|
try {
|
||||||
const res = await fetch(`/api/sessions/${sessionId}/file-content?path=${encodeURIComponent(filePath)}&lines=500`);
|
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') {
|
} else if (data.type === 'video') {
|
||||||
bodyEl.innerHTML = `<video src="${data.url}" controls autoplay></video>`;
|
bodyEl.innerHTML = `<video src="${data.url}" controls autoplay></video>`;
|
||||||
footerEl.textContent = `${this.formatFileSize(data.size)} \u2022 ${data.extension}`;
|
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') {
|
} 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';
|
footerEl.textContent = data.extension || 'binary';
|
||||||
} else {
|
} else {
|
||||||
// Text content
|
// Text content
|
||||||
|
|||||||
@@ -7239,12 +7239,22 @@ kbd {
|
|||||||
max-height: 100%;
|
max-height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.file-preview-body audio {
|
||||||
|
display: block;
|
||||||
|
width: 90%;
|
||||||
|
margin: 2rem auto;
|
||||||
|
}
|
||||||
|
|
||||||
.file-preview-body .binary-message {
|
.file-preview-body .binary-message {
|
||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.file-preview-body .binary-message a {
|
||||||
|
color: var(--accent, #4ea1ff);
|
||||||
|
}
|
||||||
|
|
||||||
.file-preview-footer {
|
.file-preview-footer {
|
||||||
padding: 0.35rem 0.75rem;
|
padding: 0.35rem 0.75rem;
|
||||||
font-size: 0.7rem;
|
font-size: 0.7rem;
|
||||||
|
|||||||
@@ -511,49 +511,73 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
|||||||
try {
|
try {
|
||||||
const stat = await fs.stat(resolvedPath);
|
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 ext = filePath.split('.').pop()?.toLowerCase() || '';
|
||||||
const binaryExts = new Set([
|
const imageExts = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp', 'ico']);
|
||||||
'png',
|
const videoExts = new Set(['mp4', 'webm', 'mov', 'm4v', 'ogv']);
|
||||||
'jpg',
|
const audioExts = new Set(['mp3', 'wav', 'ogg', 'oga', 'm4a', 'aac', 'flac', 'opus']);
|
||||||
'jpeg',
|
const otherBinaryExts = new Set([
|
||||||
'gif',
|
|
||||||
'webp',
|
|
||||||
'ico',
|
|
||||||
'svg',
|
|
||||||
'bmp',
|
|
||||||
'mp4',
|
|
||||||
'webm',
|
|
||||||
'mov',
|
|
||||||
'avi',
|
|
||||||
'mp3',
|
|
||||||
'wav',
|
|
||||||
'ogg',
|
|
||||||
'pdf',
|
'pdf',
|
||||||
'zip',
|
'zip',
|
||||||
'tar',
|
'tar',
|
||||||
'gz',
|
'gz',
|
||||||
|
'bz2',
|
||||||
|
'xz',
|
||||||
|
'7z',
|
||||||
|
'rar',
|
||||||
'exe',
|
'exe',
|
||||||
'dll',
|
'dll',
|
||||||
'so',
|
'so',
|
||||||
|
'dylib',
|
||||||
|
'bin',
|
||||||
|
'wasm',
|
||||||
|
'class',
|
||||||
|
'o',
|
||||||
|
'a',
|
||||||
'woff',
|
'woff',
|
||||||
'woff2',
|
'woff2',
|
||||||
'ttf',
|
'ttf',
|
||||||
'eot',
|
'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)) {
|
const mediaType = imageExts.has(ext)
|
||||||
// Return metadata for binary files
|
? '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 {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
data: {
|
data: {
|
||||||
path: filePath,
|
path: filePath,
|
||||||
size: stat.size,
|
size: stat.size,
|
||||||
type: imageExts.has(ext) ? 'image' : videoExts.has(ext) ? 'video' : 'binary',
|
type: mediaType ?? 'binary',
|
||||||
extension: ext,
|
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)
|
// Read text file with line limit (bounded to prevent DoS)
|
||||||
const MAX_LINES_LIMIT = 10000;
|
const MAX_LINES_LIMIT = 10000;
|
||||||
const maxLines = Math.min(parseInt(lines || '500', 10) || 500, MAX_LINES_LIMIT);
|
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 allLines = content.split('\n');
|
||||||
const truncatedContent = allLines.length > maxLines;
|
const truncatedContent = allLines.length > maxLines;
|
||||||
const displayContent = truncatedContent ? allLines.slice(0, maxLines).join('\n') : content;
|
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',
|
mp4: 'video/mp4',
|
||||||
webm: 'video/webm',
|
webm: 'video/webm',
|
||||||
mov: 'video/quicktime',
|
mov: 'video/quicktime',
|
||||||
|
m4v: 'video/mp4',
|
||||||
|
ogv: 'video/ogg',
|
||||||
mp3: 'audio/mpeg',
|
mp3: 'audio/mpeg',
|
||||||
wav: 'audio/wav',
|
wav: 'audio/wav',
|
||||||
ogg: 'audio/ogg',
|
ogg: 'audio/ogg',
|
||||||
|
oga: 'audio/ogg',
|
||||||
|
opus: 'audio/ogg',
|
||||||
|
m4a: 'audio/mp4',
|
||||||
|
aac: 'audio/aac',
|
||||||
|
flac: 'audio/flac',
|
||||||
pdf: 'application/pdf',
|
pdf: 'application/pdf',
|
||||||
json: 'application/json',
|
json: 'application/json',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -215,6 +215,50 @@ describe('file-routes', () => {
|
|||||||
expect(body.data.url).toContain('file-raw');
|
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 () => {
|
it('rejects path traversal attempts', async () => {
|
||||||
// realpathSync resolves the symlink to a path outside workingDir
|
// realpathSync resolves the symlink to a path outside workingDir
|
||||||
mockedRealpathSync.mockReturnValue('/etc/passwd' as never);
|
mockedRealpathSync.mockReturnValue('/etc/passwd' as never);
|
||||||
|
|||||||
Reference in New Issue
Block a user