Handle Symlink in the File manager. #227
@@ -329,6 +329,71 @@ app.get("/ssh/file_manager/ssh/listFiles", (req, res) => {
|
||||
});
|
||||
});
|
||||
|
||||
app.get("/ssh/file_manager/ssh/identifySymlink", (req, res) => {
|
||||
const sessionId = req.query.sessionId as string;
|
||||
const sshConn = sshSessions[sessionId];
|
||||
const linkPath = decodeURIComponent(req.query.path as string);
|
||||
|
||||
if (!sessionId) {
|
||||
return res.status(400).json({ error: "Session ID is required" });
|
||||
}
|
||||
|
||||
if (!sshConn?.isConnected) {
|
||||
return res.status(400).json({ error: "SSH connection not established" });
|
||||
}
|
||||
|
||||
if (!linkPath) {
|
||||
return res.status(400).json({ error: "Link path is required" });
|
||||
}
|
||||
|
||||
sshConn.lastActive = Date.now();
|
||||
|
||||
const escapedPath = linkPath.replace(/'/g, "'\"'\"'");
|
||||
const command = `stat -L -c "%F" '${escapedPath}' && readlink -f '${escapedPath}'`;
|
||||
|
||||
sshConn.client.exec(command, (err, stream) => {
|
||||
if (err) {
|
||||
fileLogger.error("SSH identifySymlink error:", err);
|
||||
return res.status(500).json({ error: err.message });
|
||||
}
|
||||
|
||||
let data = "";
|
||||
let errorData = "";
|
||||
|
||||
stream.on("data", (chunk: Buffer) => {
|
||||
data += chunk.toString();
|
||||
});
|
||||
|
||||
stream.stderr.on("data", (chunk: Buffer) => {
|
||||
errorData += chunk.toString();
|
||||
});
|
||||
|
||||
stream.on("close", (code) => {
|
||||
if (code !== 0) {
|
||||
fileLogger.error(
|
||||
`SSH identifySymlink command failed with code ${code}: ${errorData.replace(/\n/g, " ").trim()}`,
|
||||
);
|
||||
return res.status(500).json({ error: `Command failed: ${errorData}` });
|
||||
}
|
||||
|
|
||||
|
||||
const [fileType, target] = data.trim().split("\n");
|
||||
|
||||
res.json({
|
||||
path: linkPath,
|
||||
target: target,
|
||||
type: fileType.toLowerCase().includes("directory") ? "directory" : "file"
|
||||
});
|
||||
});
|
||||
|
||||
stream.on("error", (streamErr) => {
|
||||
fileLogger.error("SSH identifySymlink stream error:", streamErr);
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({ error: `Stream error: ${streamErr.message}` });
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
⚠️ Potential issue Guard against missing/invalid path and harden output parsing. decodeURIComponent is called before validating presence; undefined becomes the string "undefined" and the command runs against that path. Also, parsing assumes exactly two non-empty lines. Tighten both. Apply: 📝 Committable suggestion
🤖 Prompt for AI Agents_⚠️ Potential issue_
**Guard against missing/invalid path and harden output parsing.**
decodeURIComponent is called before validating presence; undefined becomes the string "undefined" and the command runs against that path. Also, parsing assumes exactly two non-empty lines. Tighten both.
Apply:
```diff
-app.get("/ssh/file_manager/ssh/identifySymlink", (req, res) => {
- const sessionId = req.query.sessionId as string;
- const sshConn = sshSessions[sessionId];
- const linkPath = decodeURIComponent(req.query.path as string);
+app.get("/ssh/file_manager/ssh/identifySymlink", (req, res) => {
+ const sessionId = req.query.sessionId as string;
+ const sshConn = sshSessions[sessionId];
+ const rawPath = req.query.path;
+ if (typeof rawPath !== "string" || rawPath.trim() === "") {
+ return res.status(400).json({ error: "Link path is required" });
+ }
+ const linkPath = decodeURIComponent(rawPath);
@@
- sshConn.client.exec(command, (err, stream) => {
+ sshConn.client.exec(command, (err, stream) => {
@@
- stream.on("close", (code) => {
+ stream.on("close", (code) => {
if (code !== 0) {
@@
- const [fileType, target] = data.trim().split("\n");
-
+ const lines = data.split(/\r?\n/).map(s => s.trim()).filter(Boolean);
+ if (lines.length < 2) {
+ fileLogger.error(`SSH identifySymlink command returned unexpected output: ${JSON.stringify(lines)}`);
+ return res.status(500).json({ error: "Unexpected command output while resolving link" });
+ }
+ const [fileType, target] = lines;
+
res.json({
path: linkPath,
target: target,
type: fileType.toLowerCase().includes("directory") ? "directory" : "file"
});
```
<!-- suggestion_start -->
<details>
<summary>📝 Committable suggestion</summary>
> ‼️ **IMPORTANT**
> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
```suggestion
app.get("/ssh/file_manager/ssh/identifySymlink", (req, res) => {
const sessionId = req.query.sessionId as string;
const sshConn = sshSessions[sessionId];
const rawPath = req.query.path;
if (typeof rawPath !== "string" || rawPath.trim() === "") {
return res.status(400).json({ error: "Link path is required" });
}
const linkPath = decodeURIComponent(rawPath);
if (!sessionId) {
return res.status(400).json({ error: "Session ID is required" });
}
if (!sshConn?.isConnected) {
return res.status(400).json({ error: "SSH connection not established" });
}
if (!linkPath) {
return res.status(400).json({ error: "Link path is required" });
}
sshConn.lastActive = Date.now();
const escapedPath = linkPath.replace(/'/g, "'\"'\"'");
const command = `stat -L -c "%F" '${escapedPath}' && readlink -f '${escapedPath}'`;
sshConn.client.exec(command, (err, stream) => {
if (err) {
fileLogger.error("SSH identifySymlink error:", err);
return res.status(500).json({ error: err.message });
}
let data = "";
let errorData = "";
stream.on("data", (chunk: Buffer) => {
data += chunk.toString();
});
stream.stderr.on("data", (chunk: Buffer) => {
errorData += chunk.toString();
});
stream.on("close", (code) => {
if (code !== 0) {
fileLogger.error(
`SSH identifySymlink command failed with code ${code}: ${errorData.replace(/\n/g, " ").trim()}`,
);
return res.status(500).json({ error: `Command failed: ${errorData}` });
}
const lines = data.split(/\r?\n/).map(s => s.trim()).filter(Boolean);
if (lines.length < 2) {
fileLogger.error(`SSH identifySymlink command returned unexpected output: ${JSON.stringify(lines)}`);
return res.status(500).json({ error: "Unexpected command output while resolving link" });
}
const [fileType, target] = lines;
res.json({
path: linkPath,
target: target,
type: fileType.toLowerCase().includes("directory") ? "directory" : "file"
});
});
stream.on("error", (streamErr) => {
fileLogger.error("SSH identifySymlink stream error:", streamErr);
if (!res.headersSent) {
res.status(500).json({ error: `Stream error: ${streamErr.message}` });
}
});
});
});
```
</details>
<!-- suggestion_end -->
<details>
<summary>🤖 Prompt for AI Agents</summary>
```
In src/backend/ssh/file-manager.ts around lines 332-396, decodeURIComponent is
called before validating the raw query path and the output parsing assumes
exactly two non-empty lines; fix by first reading and validating req.query.path
(return 400 if missing/empty), then safely decode it inside a try/catch (return
400 if decoding fails), continue to escape the decoded path for the shell, and
after command completes robustly parse output by trimming and splitting into
lines, treating missing lines gracefully (e.g. set target = lines[1] || "" and
fileType = lines[0] || "" and if fileType is empty return 500 or an informative
error), and ensure responses are only sent once (check res.headersSent) when
handling stream errors or command failures.
```
</details>
<!-- fingerprinting:phantom:medusa:chinchilla -->
<!-- This is an auto-generated comment by CodeRabbit -->
|
||||
app.get("/ssh/file_manager/ssh/readFile", (req, res) => {
|
||||
const sessionId = req.query.sessionId as string;
|
||||
const sshConn = sshSessions[sessionId];
|
||||
|
||||
@@ -8,6 +8,7 @@ import React, {
|
||||
import {
|
||||
Folder,
|
||||
File,
|
||||
FileSymlink,
|
||||
ArrowUp,
|
||||
Pin,
|
||||
MoreVertical,
|
||||
@@ -29,6 +30,7 @@ import {
|
||||
removeFileManagerPinned,
|
||||
getSSHStatus,
|
||||
connectSSH,
|
||||
identifySSHSymlink,
|
||||
} from "@/ui/main-axios.ts";
|
||||
import type { SSHHost } from "../../../types/index.js";
|
||||
|
||||
@@ -339,7 +341,7 @@ const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar(
|
||||
try {
|
||||
await renameSSHItem(sshSessionId, item.path, newName.trim());
|
||||
toast.success(
|
||||
`${item.type === "directory" ? t("common.folder") : t("common.file")} ${t("common.renamedSuccessfully")}`,
|
||||
`${item.type === "directory" ? t("common.folder") : item.type === "link" ? t("common.link") : t("common.file")} ${t("common.renamedSuccessfully")}`,
|
||||
);
|
||||
setRenamingItem(null);
|
||||
if (onOperationComplete) {
|
||||
@@ -375,6 +377,74 @@ const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar(
|
||||
onPathChange?.(newPath);
|
||||
};
|
||||
|
||||
// Handle symlink resolution
|
||||
const handleSymlinkClick = async (item: any) => {
|
||||
if (!host) return;
|
||||
|
||||
try {
|
||||
// Extract just the symlink path (before the " -> " if present)
|
||||
const symlinkPath = item.path.includes(" -> ")
|
||||
? item.path.split(" -> ")[0]
|
||||
: item.path;
|
||||
|
||||
let currentSessionId = sshSessionId;
|
||||
|
||||
// Check SSH connection status and reconnect if needed
|
||||
if (currentSessionId) {
|
||||
try {
|
||||
const status = await getSSHStatus(currentSessionId);
|
||||
if (!status.connected) {
|
||||
const newSessionId = await connectToSSH(host);
|
||||
if (newSessionId) {
|
||||
setSshSessionId(newSessionId);
|
||||
currentSessionId = newSessionId;
|
||||
} else {
|
||||
throw new Error(t("fileManager.failedToReconnectSSH"));
|
||||
}
|
||||
}
|
||||
} catch (sessionErr) {
|
||||
const newSessionId = await connectToSSH(host);
|
||||
if (newSessionId) {
|
||||
setSshSessionId(newSessionId);
|
||||
currentSessionId = newSessionId;
|
||||
} else {
|
||||
throw sessionErr;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// No session ID, try to connect
|
||||
const newSessionId = await connectToSSH(host);
|
||||
if (newSessionId) {
|
||||
setSshSessionId(newSessionId);
|
||||
currentSessionId = newSessionId;
|
||||
} else {
|
||||
throw new Error(t("fileManager.failedToConnectSSH"));
|
||||
}
|
||||
}
|
||||
|
||||
const symlinkInfo = await identifySSHSymlink(currentSessionId, symlinkPath);
|
||||
|
||||
if (symlinkInfo.type === "directory") {
|
||||
// If symlink points to a directory, navigate to it
|
||||
handlePathChange(symlinkInfo.target);
|
||||
} else if (symlinkInfo.type === "file") {
|
||||
// If symlink points to a file, open it as a file
|
||||
onOpenFile({
|
||||
name: item.name,
|
||||
path: symlinkInfo.target, // Use the target path, not the symlink path
|
||||
isSSH: item.isSSH,
|
||||
sshSessionId: currentSessionId,
|
||||
});
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast.error(
|
||||
error?.response?.data?.error ||
|
||||
error?.message ||
|
||||
t("fileManager.failedToResolveSymlink"),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full w-[256px] max-w-[256px]">
|
||||
<div className="flex flex-col flex-grow min-h-0">
|
||||
@@ -456,6 +526,8 @@ const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar(
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
{item.type === "directory" ? (
|
||||
<Folder className="w-4 h-4 text-blue-400 flex-shrink-0" />
|
||||
) : item.type === "link" ? (
|
||||
<FileSymlink className="w-4 h-4 text-blue-400 flex-shrink-0" />
|
||||
) : (
|
||||
<File className="w-4 h-4 text-muted-foreground flex-shrink-0" />
|
||||
)}
|
||||
@@ -496,6 +568,8 @@ const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar(
|
||||
!isOpen &&
|
||||
(item.type === "directory"
|
||||
? handlePathChange(item.path)
|
||||
: item.type === "link"
|
||||
? handleSymlinkClick(item)
|
||||
: onOpenFile({
|
||||
name: item.name,
|
||||
path: item.path,
|
||||
@@ -506,6 +580,8 @@ const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar(
|
||||
>
|
||||
{item.type === "directory" ? (
|
||||
<Folder className="w-4 h-4 text-blue-400 flex-shrink-0" />
|
||||
) : item.type === "link" ? (
|
||||
<FileSymlink className="w-4 h-4 text-blue-400 flex-shrink-0" />
|
||||
) : (
|
||||
<File className="w-4 h-4 text-muted-foreground flex-shrink-0" />
|
||||
)}
|
||||
@@ -514,7 +590,7 @@ const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar(
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{item.type === "file" && (
|
||||
{(item.type === "file") && (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
|
||||
@@ -969,6 +969,20 @@ export async function listSSHFiles(
|
||||
}
|
||||
}
|
||||
|
||||
export async function identifySSHSymlink(
|
||||
sessionId: string,
|
||||
path: string,
|
||||
): Promise<{ path: string; target: string; type: "directory" | "file" }> {
|
||||
try {
|
||||
const response = await fileManagerApi.get("/ssh/identifySymlink", {
|
||||
params: { sessionId, path },
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "identify SSH symlink");
|
||||
}
|
||||
}
|
||||
|
||||
export async function readSSHFile(
|
||||
sessionId: string,
|
||||
path: string,
|
||||
|
||||
Reference in New Issue
Block a user
🛠️ Refactor suggestion
Return a specific status for broken links.
If stderr contains “No such file or directory”, respond with 404 to let the UI show a clearer toast for broken symlinks instead of a generic 500.
Apply:
📝 Committable suggestion
🤖 Prompt for AI Agents