feat(chat): add Mermaid fullscreen preview and harden file preview paths (#490)

* feat(chat): add mermaid preview popups and fullscreen diagram viewer

Enable opening Mermaid diagrams from markdown and file attachments with a dedicated fullscreen dialog, while tightening preview loading behavior and sizing for more reliable interaction.

* refactor(chat): extract shared preview overlay hooks

Centralize fullscreen preview transition and viewport lifecycle logic so image and Mermaid dialogs stay behaviorally aligned while reducing maintenance overhead.

* fix(security): enforce workspace boundaries for fs read endpoints

Validate /api/fs/read and /api/fs/raw paths against active workspace roots and canonical realpaths to block traversal and symlink escapes before serving file contents.

* fix(chat): remove dead Mermaid copy component

Drop an unused merge-leftover component in MarkdownRenderer to keep lint clean without changing Mermaid preview behavior.
This commit is contained in:
Iuliia Ivashko
2026-02-24 01:49:06 +02:00
committed by GitHub
parent b2101acfbf
commit d2d39c48ac
11 changed files with 882 additions and 157 deletions
+29 -11
View File
@@ -10432,17 +10432,26 @@ Context:
}
try {
const resolvedPath = path.resolve(normalizeDirectoryPath(filePath));
if (resolvedPath.includes('..')) {
return res.status(400).json({ error: 'Invalid path: path traversal not allowed' });
const resolved = await resolveWorkspacePathFromContext(req, filePath);
if (!resolved.ok) {
return res.status(400).json({ error: resolved.error });
}
const stats = await fsPromises.stat(resolvedPath);
const [canonicalPath, canonicalBase] = await Promise.all([
fsPromises.realpath(resolved.resolved),
fsPromises.realpath(resolved.base).catch(() => path.resolve(resolved.base)),
]);
if (!isPathWithinRoot(canonicalPath, canonicalBase)) {
return res.status(403).json({ error: 'Access to file denied' });
}
const stats = await fsPromises.stat(canonicalPath);
if (!stats.isFile()) {
return res.status(400).json({ error: 'Specified path is not a file' });
}
const content = await fsPromises.readFile(resolvedPath, 'utf8');
const content = await fsPromises.readFile(canonicalPath, 'utf8');
res.type('text/plain').send(content);
} catch (error) {
const err = error;
@@ -10465,17 +10474,26 @@ Context:
}
try {
const resolvedPath = path.resolve(normalizeDirectoryPath(filePath));
if (resolvedPath.includes('..')) {
return res.status(400).json({ error: 'Invalid path: path traversal not allowed' });
const resolved = await resolveWorkspacePathFromContext(req, filePath);
if (!resolved.ok) {
return res.status(400).json({ error: resolved.error });
}
const stats = await fsPromises.stat(resolvedPath);
const [canonicalPath, canonicalBase] = await Promise.all([
fsPromises.realpath(resolved.resolved),
fsPromises.realpath(resolved.base).catch(() => path.resolve(resolved.base)),
]);
if (!isPathWithinRoot(canonicalPath, canonicalBase)) {
return res.status(403).json({ error: 'Access to file denied' });
}
const stats = await fsPromises.stat(canonicalPath);
if (!stats.isFile()) {
return res.status(400).json({ error: 'Specified path is not a file' });
}
const ext = path.extname(resolvedPath).toLowerCase();
const ext = path.extname(canonicalPath).toLowerCase();
const mimeMap = {
'.png': 'image/png',
'.jpg': 'image/jpeg',
@@ -10489,7 +10507,7 @@ Context:
};
const mimeType = mimeMap[ext] || 'application/octet-stream';
const content = await fsPromises.readFile(resolvedPath);
const content = await fsPromises.readFile(canonicalPath);
res.setHeader('Cache-Control', 'no-store');
res.type(mimeType).send(content);
} catch (error) {