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:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user