- Enable SSH keepalive mechanism (keepaliveCountMax: 0 -> 3) - Set proper ready timeout (0 -> 60000ms) - Implement session cleanup with 10-minute timeout - Add scheduleSessionCleanup call on connection ready Resolves random disconnections every 2-3 minutes during file editing.
2060 lines
59 KiB
TypeScript
2060 lines
59 KiB
TypeScript
import express from "express";
|
||
import cors from "cors";
|
||
import { Client as SSHClient } from "ssh2";
|
||
import { db } from "../database/db/index.js";
|
||
import { sshCredentials } from "../database/db/schema.js";
|
||
import { eq, and } from "drizzle-orm";
|
||
import { fileLogger } from "../utils/logger.js";
|
||
import { EncryptedDBOperations } from "../utils/encrypted-db-operations.js";
|
||
|
||
// 可执行文件检测工具函数
|
||
function isExecutableFile(permissions: string, fileName: string): boolean {
|
||
// 检查执行权限位 (user, group, other)
|
||
const hasExecutePermission =
|
||
permissions[3] === "x" || permissions[6] === "x" || permissions[9] === "x";
|
||
|
||
// 常见的脚本文件扩展名
|
||
const scriptExtensions = [
|
||
".sh",
|
||
".py",
|
||
".pl",
|
||
".rb",
|
||
".js",
|
||
".php",
|
||
".bash",
|
||
".zsh",
|
||
".fish",
|
||
];
|
||
const hasScriptExtension = scriptExtensions.some((ext) =>
|
||
fileName.toLowerCase().endsWith(ext),
|
||
);
|
||
|
||
// 常见的编译可执行文件(无扩展名或特定扩展名)
|
||
const executableExtensions = [".bin", ".exe", ".out"];
|
||
const hasExecutableExtension = executableExtensions.some((ext) =>
|
||
fileName.toLowerCase().endsWith(ext),
|
||
);
|
||
|
||
// 无扩展名且有执行权限的文件通常是可执行文件
|
||
const hasNoExtension = !fileName.includes(".") && hasExecutePermission;
|
||
|
||
return (
|
||
hasExecutePermission &&
|
||
(hasScriptExtension || hasExecutableExtension || hasNoExtension)
|
||
);
|
||
}
|
||
|
||
const app = express();
|
||
|
||
app.use(
|
||
cors({
|
||
origin: "*",
|
||
methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
|
||
allowedHeaders: [
|
||
"Content-Type",
|
||
"Authorization",
|
||
"User-Agent",
|
||
"X-Electron-App",
|
||
],
|
||
}),
|
||
);
|
||
app.use(express.json({ limit: "100mb" }));
|
||
app.use(express.urlencoded({ limit: "100mb", extended: true }));
|
||
app.use(express.raw({ limit: "200mb", type: "application/octet-stream" }));
|
||
|
||
interface SSHSession {
|
||
client: SSHClient;
|
||
isConnected: boolean;
|
||
lastActive: number;
|
||
timeout?: NodeJS.Timeout;
|
||
}
|
||
|
||
const sshSessions: Record<string, SSHSession> = {};
|
||
|
||
function cleanupSession(sessionId: string) {
|
||
const session = sshSessions[sessionId];
|
||
if (session) {
|
||
try {
|
||
session.client.end();
|
||
} catch {}
|
||
clearTimeout(session.timeout);
|
||
delete sshSessions[sessionId];
|
||
}
|
||
}
|
||
|
||
function scheduleSessionCleanup(sessionId: string) {
|
||
const session = sshSessions[sessionId];
|
||
if (session) {
|
||
// Clear existing timeout
|
||
if (session.timeout) clearTimeout(session.timeout);
|
||
|
||
// Set new timeout for 10 minutes of inactivity
|
||
session.timeout = setTimeout(() => {
|
||
fileLogger.info(`Cleaning up inactive SSH session: ${sessionId}`);
|
||
cleanupSession(sessionId);
|
||
}, 10 * 60 * 1000); // 10 minutes
|
||
}
|
||
}
|
||
|
||
app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
|
||
const {
|
||
sessionId,
|
||
hostId,
|
||
ip,
|
||
port,
|
||
username,
|
||
password,
|
||
sshKey,
|
||
keyPassword,
|
||
authType,
|
||
credentialId,
|
||
userId,
|
||
} = req.body;
|
||
|
||
if (!sessionId || !ip || !username || !port) {
|
||
fileLogger.warn("Missing SSH connection parameters for file manager", {
|
||
operation: "file_connect",
|
||
sessionId,
|
||
hasIp: !!ip,
|
||
hasUsername: !!username,
|
||
hasPort: !!port,
|
||
});
|
||
return res.status(400).json({ error: "Missing SSH connection parameters" });
|
||
}
|
||
|
||
if (sshSessions[sessionId]?.isConnected) {
|
||
cleanupSession(sessionId);
|
||
}
|
||
const client = new SSHClient();
|
||
|
||
let resolvedCredentials = { password, sshKey, keyPassword, authType };
|
||
if (credentialId && hostId && userId) {
|
||
try {
|
||
const credentials = await EncryptedDBOperations.select(
|
||
db
|
||
.select()
|
||
.from(sshCredentials)
|
||
.where(
|
||
and(
|
||
eq(sshCredentials.id, credentialId),
|
||
eq(sshCredentials.userId, userId),
|
||
),
|
||
),
|
||
"ssh_credentials",
|
||
);
|
||
|
||
if (credentials.length > 0) {
|
||
const credential = credentials[0];
|
||
resolvedCredentials = {
|
||
password: credential.password,
|
||
sshKey: credential.privateKey || credential.key, // prefer new privateKey field
|
||
keyPassword: credential.keyPassword,
|
||
authType: credential.authType,
|
||
};
|
||
} else {
|
||
fileLogger.warn(`No credentials found for host ${hostId}`, {
|
||
operation: "ssh_credentials",
|
||
hostId,
|
||
credentialId,
|
||
userId,
|
||
});
|
||
}
|
||
} catch (error) {
|
||
fileLogger.warn(`Failed to resolve credentials for host ${hostId}`, {
|
||
operation: "ssh_credentials",
|
||
hostId,
|
||
credentialId,
|
||
error: error instanceof Error ? error.message : "Unknown error",
|
||
});
|
||
}
|
||
} else if (credentialId && hostId) {
|
||
fileLogger.warn(
|
||
"Missing userId for credential resolution in file manager",
|
||
{
|
||
operation: "ssh_credentials",
|
||
hostId,
|
||
credentialId,
|
||
hasUserId: !!userId,
|
||
},
|
||
);
|
||
}
|
||
|
||
const config: any = {
|
||
host: ip,
|
||
port: port || 22,
|
||
username,
|
||
readyTimeout: 60000,
|
||
keepaliveInterval: 30000,
|
||
keepaliveCountMax: 3,
|
||
algorithms: {
|
||
kex: [
|
||
"diffie-hellman-group14-sha256",
|
||
"diffie-hellman-group14-sha1",
|
||
"diffie-hellman-group1-sha1",
|
||
"diffie-hellman-group-exchange-sha256",
|
||
"diffie-hellman-group-exchange-sha1",
|
||
"ecdh-sha2-nistp256",
|
||
"ecdh-sha2-nistp384",
|
||
"ecdh-sha2-nistp521",
|
||
],
|
||
cipher: [
|
||
"aes128-ctr",
|
||
"aes192-ctr",
|
||
"aes256-ctr",
|
||
"aes128-gcm@openssh.com",
|
||
"aes256-gcm@openssh.com",
|
||
"aes128-cbc",
|
||
"aes192-cbc",
|
||
"aes256-cbc",
|
||
"3des-cbc",
|
||
],
|
||
hmac: ["hmac-sha2-256", "hmac-sha2-512", "hmac-sha1", "hmac-md5"],
|
||
compress: ["none", "zlib@openssh.com", "zlib"],
|
||
},
|
||
};
|
||
|
||
if (resolvedCredentials.sshKey && resolvedCredentials.sshKey.trim()) {
|
||
try {
|
||
if (
|
||
!resolvedCredentials.sshKey.includes("-----BEGIN") ||
|
||
!resolvedCredentials.sshKey.includes("-----END")
|
||
) {
|
||
throw new Error("Invalid private key format");
|
||
}
|
||
|
||
const cleanKey = resolvedCredentials.sshKey
|
||
.trim()
|
||
.replace(/\r\n/g, "\n")
|
||
.replace(/\r/g, "\n");
|
||
|
||
config.privateKey = Buffer.from(cleanKey, "utf8");
|
||
|
||
if (resolvedCredentials.keyPassword)
|
||
config.passphrase = resolvedCredentials.keyPassword;
|
||
} catch (keyError) {
|
||
fileLogger.error("SSH key format error for file manager", {
|
||
operation: "file_connect",
|
||
sessionId,
|
||
hostId,
|
||
error: keyError.message,
|
||
});
|
||
return res.status(400).json({ error: "Invalid SSH key format" });
|
||
}
|
||
} else if (
|
||
resolvedCredentials.password &&
|
||
resolvedCredentials.password.trim()
|
||
) {
|
||
config.password = resolvedCredentials.password;
|
||
} else {
|
||
fileLogger.warn("No authentication method provided for file manager", {
|
||
operation: "file_connect",
|
||
sessionId,
|
||
hostId,
|
||
});
|
||
return res
|
||
.status(400)
|
||
.json({ error: "Either password or SSH key must be provided" });
|
||
}
|
||
|
||
let responseSent = false;
|
||
|
||
client.on("ready", () => {
|
||
if (responseSent) return;
|
||
responseSent = true;
|
||
sshSessions[sessionId] = {
|
||
client,
|
||
isConnected: true,
|
||
lastActive: Date.now(),
|
||
};
|
||
scheduleSessionCleanup(sessionId);
|
||
res.json({ status: "success", message: "SSH connection established" });
|
||
});
|
||
|
||
client.on("error", (err) => {
|
||
if (responseSent) return;
|
||
responseSent = true;
|
||
fileLogger.error("SSH connection failed for file manager", {
|
||
operation: "file_connect",
|
||
sessionId,
|
||
hostId,
|
||
ip,
|
||
port,
|
||
username,
|
||
error: err.message,
|
||
});
|
||
res.status(500).json({ status: "error", message: err.message });
|
||
});
|
||
|
||
client.on("close", () => {
|
||
if (sshSessions[sessionId]) sshSessions[sessionId].isConnected = false;
|
||
cleanupSession(sessionId);
|
||
});
|
||
|
||
client.connect(config);
|
||
});
|
||
|
||
app.post("/ssh/file_manager/ssh/disconnect", (req, res) => {
|
||
const { sessionId } = req.body;
|
||
cleanupSession(sessionId);
|
||
res.json({ status: "success", message: "SSH connection disconnected" });
|
||
});
|
||
|
||
app.get("/ssh/file_manager/ssh/status", (req, res) => {
|
||
const sessionId = req.query.sessionId as string;
|
||
const isConnected = !!sshSessions[sessionId]?.isConnected;
|
||
res.json({ status: "success", connected: isConnected });
|
||
});
|
||
|
||
app.get("/ssh/file_manager/ssh/listFiles", (req, res) => {
|
||
const sessionId = req.query.sessionId as string;
|
||
const sshConn = sshSessions[sessionId];
|
||
const sshPath = 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" });
|
||
}
|
||
|
||
sshConn.lastActive = Date.now();
|
||
|
||
const escapedPath = sshPath.replace(/'/g, "'\"'\"'");
|
||
sshConn.client.exec(`ls -la '${escapedPath}'`, (err, stream) => {
|
||
if (err) {
|
||
fileLogger.error("SSH listFiles 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 listFiles command failed with code ${code}: ${errorData.replace(/\n/g, " ").trim()}`,
|
||
);
|
||
return res.status(500).json({ error: `Command failed: ${errorData}` });
|
||
}
|
||
|
||
const lines = data.split("\n").filter((line) => line.trim());
|
||
const files = [];
|
||
|
||
for (let i = 1; i < lines.length; i++) {
|
||
const line = lines[i];
|
||
const parts = line.split(/\s+/);
|
||
if (parts.length >= 9) {
|
||
const permissions = parts[0];
|
||
const linkCount = parts[1];
|
||
const owner = parts[2];
|
||
const group = parts[3];
|
||
const size = parseInt(parts[4], 10);
|
||
|
||
// 日期可能占夨3个部分(月 日 时间)或者是(月 日 年)
|
||
let dateStr = "";
|
||
let nameStartIndex = 8;
|
||
|
||
if (parts[5] && parts[6] && parts[7]) {
|
||
// 常规格式: 月 日 时间/年
|
||
dateStr = `${parts[5]} ${parts[6]} ${parts[7]}`;
|
||
}
|
||
|
||
const name = parts.slice(nameStartIndex).join(" ");
|
||
const isDirectory = permissions.startsWith("d");
|
||
const isLink = permissions.startsWith("l");
|
||
|
||
if (name === "." || name === "..") continue;
|
||
|
||
// 解析符号链接目标
|
||
let actualName = name;
|
||
let linkTarget = undefined;
|
||
if (isLink && name.includes(" -> ")) {
|
||
const linkParts = name.split(" -> ");
|
||
actualName = linkParts[0];
|
||
linkTarget = linkParts[1];
|
||
}
|
||
|
||
files.push({
|
||
name: actualName,
|
||
type: isDirectory ? "directory" : isLink ? "link" : "file",
|
||
size: isDirectory ? undefined : size, // 目录不显示大小
|
||
modified: dateStr,
|
||
permissions,
|
||
owner,
|
||
group,
|
||
linkTarget, // 符号链接的目标
|
||
path: `${sshPath.endsWith("/") ? sshPath : sshPath + "/"}${actualName}`, // 添加完整路径
|
||
executable:
|
||
!isDirectory && !isLink
|
||
? isExecutableFile(permissions, actualName)
|
||
: false, // 检测可执行文件
|
||
});
|
||
}
|
||
}
|
||
|
||
res.json({ files, path: sshPath });
|
||
});
|
||
});
|
||
});
|
||
|
||
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}` });
|
||
}
|
||
});
|
||
});
|
||
});
|
||
|
||
app.get("/ssh/file_manager/ssh/readFile", (req, res) => {
|
||
const sessionId = req.query.sessionId as string;
|
||
const sshConn = sshSessions[sessionId];
|
||
const filePath = 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 (!filePath) {
|
||
return res.status(400).json({ error: "File path is required" });
|
||
}
|
||
|
||
sshConn.lastActive = Date.now();
|
||
|
||
// First check file size to prevent loading huge files
|
||
const MAX_READ_SIZE = 10 * 1024 * 1024; // 10MB - same as frontend limit
|
||
const escapedPath = filePath.replace(/'/g, "'\"'\"'");
|
||
|
||
// Get file size first
|
||
sshConn.client.exec(
|
||
`stat -c%s '${escapedPath}' 2>/dev/null || wc -c < '${escapedPath}'`,
|
||
(sizeErr, sizeStream) => {
|
||
if (sizeErr) {
|
||
fileLogger.error("SSH file size check error:", sizeErr);
|
||
return res.status(500).json({ error: sizeErr.message });
|
||
}
|
||
|
||
let sizeData = "";
|
||
let sizeErrorData = "";
|
||
|
||
sizeStream.on("data", (chunk: Buffer) => {
|
||
sizeData += chunk.toString();
|
||
});
|
||
|
||
sizeStream.stderr.on("data", (chunk: Buffer) => {
|
||
sizeErrorData += chunk.toString();
|
||
});
|
||
|
||
sizeStream.on("close", (sizeCode) => {
|
||
if (sizeCode !== 0) {
|
||
fileLogger.error(`File size check failed: ${sizeErrorData}`);
|
||
return res
|
||
.status(500)
|
||
.json({ error: `Cannot check file size: ${sizeErrorData}` });
|
||
}
|
||
|
||
const fileSize = parseInt(sizeData.trim(), 10);
|
||
|
||
if (isNaN(fileSize)) {
|
||
fileLogger.error("Invalid file size response:", sizeData);
|
||
return res.status(500).json({ error: "Cannot determine file size" });
|
||
}
|
||
|
||
// Check if file is too large
|
||
if (fileSize > MAX_READ_SIZE) {
|
||
fileLogger.warn("File too large for reading", {
|
||
operation: "file_read",
|
||
sessionId,
|
||
filePath,
|
||
fileSize,
|
||
maxSize: MAX_READ_SIZE,
|
||
});
|
||
return res.status(400).json({
|
||
error: `File too large to open in editor. Maximum size is ${MAX_READ_SIZE / 1024 / 1024}MB, file is ${(fileSize / 1024 / 1024).toFixed(2)}MB. Use download instead.`,
|
||
fileSize,
|
||
maxSize: MAX_READ_SIZE,
|
||
tooLarge: true,
|
||
});
|
||
}
|
||
|
||
// File size is acceptable, proceed with reading
|
||
sshConn.client.exec(`cat '${escapedPath}'`, (err, stream) => {
|
||
if (err) {
|
||
fileLogger.error("SSH readFile 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 readFile command failed with code ${code}: ${errorData.replace(/\n/g, " ").trim()}`,
|
||
);
|
||
return res
|
||
.status(500)
|
||
.json({ error: `Command failed: ${errorData}` });
|
||
}
|
||
|
||
res.json({ content: data, path: filePath });
|
||
});
|
||
});
|
||
});
|
||
},
|
||
);
|
||
});
|
||
|
||
app.post("/ssh/file_manager/ssh/writeFile", async (req, res) => {
|
||
const { sessionId, path: filePath, content, hostId, userId } = req.body;
|
||
const sshConn = sshSessions[sessionId];
|
||
|
||
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 (!filePath) {
|
||
return res.status(400).json({ error: "File path is required" });
|
||
}
|
||
|
||
if (content === undefined) {
|
||
return res.status(400).json({ error: "File content is required" });
|
||
}
|
||
|
||
sshConn.lastActive = Date.now();
|
||
|
||
const trySFTP = () => {
|
||
try {
|
||
sshConn.client.sftp((err, sftp) => {
|
||
if (err) {
|
||
fileLogger.warn(
|
||
`SFTP failed, trying fallback method: ${err.message}`,
|
||
);
|
||
tryFallbackMethod();
|
||
return;
|
||
}
|
||
|
||
let fileBuffer;
|
||
try {
|
||
if (typeof content === "string") {
|
||
fileBuffer = Buffer.from(content, "utf8");
|
||
} else if (Buffer.isBuffer(content)) {
|
||
fileBuffer = content;
|
||
} else {
|
||
fileBuffer = Buffer.from(content);
|
||
}
|
||
} catch (bufferErr) {
|
||
fileLogger.error("Buffer conversion error:", bufferErr);
|
||
if (!res.headersSent) {
|
||
return res
|
||
.status(500)
|
||
.json({ error: "Invalid file content format" });
|
||
}
|
||
return;
|
||
}
|
||
|
||
const writeStream = sftp.createWriteStream(filePath);
|
||
|
||
let hasError = false;
|
||
let hasFinished = false;
|
||
|
||
writeStream.on("error", (streamErr) => {
|
||
if (hasError || hasFinished) return;
|
||
hasError = true;
|
||
fileLogger.warn(
|
||
`SFTP write failed, trying fallback method: ${streamErr.message}`,
|
||
);
|
||
tryFallbackMethod();
|
||
});
|
||
|
||
writeStream.on("finish", () => {
|
||
if (hasError || hasFinished) return;
|
||
hasFinished = true;
|
||
if (!res.headersSent) {
|
||
res.json({
|
||
message: "File written successfully",
|
||
path: filePath,
|
||
toast: { type: "success", message: `File written: ${filePath}` },
|
||
});
|
||
}
|
||
});
|
||
|
||
writeStream.on("close", () => {
|
||
if (hasError || hasFinished) return;
|
||
hasFinished = true;
|
||
if (!res.headersSent) {
|
||
res.json({
|
||
message: "File written successfully",
|
||
path: filePath,
|
||
toast: { type: "success", message: `File written: ${filePath}` },
|
||
});
|
||
}
|
||
});
|
||
|
||
try {
|
||
writeStream.write(fileBuffer);
|
||
writeStream.end();
|
||
} catch (writeErr) {
|
||
if (hasError || hasFinished) return;
|
||
hasError = true;
|
||
fileLogger.warn(
|
||
`SFTP write operation failed, trying fallback method: ${writeErr.message}`,
|
||
);
|
||
tryFallbackMethod();
|
||
}
|
||
});
|
||
} catch (sftpErr) {
|
||
fileLogger.warn(
|
||
`SFTP connection error, trying fallback method: ${sftpErr.message}`,
|
||
);
|
||
tryFallbackMethod();
|
||
}
|
||
};
|
||
|
||
const tryFallbackMethod = () => {
|
||
try {
|
||
const base64Content = Buffer.from(content, "utf8").toString("base64");
|
||
const escapedPath = filePath.replace(/'/g, "'\"'\"'");
|
||
|
||
const writeCommand = `echo '${base64Content}' | base64 -d > '${escapedPath}' && echo "SUCCESS"`;
|
||
|
||
sshConn.client.exec(writeCommand, (err, stream) => {
|
||
if (err) {
|
||
fileLogger.error("Fallback write command failed:", err);
|
||
if (!res.headersSent) {
|
||
return res.status(500).json({
|
||
error: `Write failed: ${err.message}`,
|
||
toast: { type: "error", message: `Write failed: ${err.message}` },
|
||
});
|
||
}
|
||
return;
|
||
}
|
||
|
||
let outputData = "";
|
||
let errorData = "";
|
||
|
||
stream.on("data", (chunk: Buffer) => {
|
||
outputData += chunk.toString();
|
||
});
|
||
|
||
stream.stderr.on("data", (chunk: Buffer) => {
|
||
errorData += chunk.toString();
|
||
});
|
||
|
||
stream.on("close", (code) => {
|
||
if (outputData.includes("SUCCESS")) {
|
||
if (!res.headersSent) {
|
||
res.json({
|
||
message: "File written successfully",
|
||
path: filePath,
|
||
toast: {
|
||
type: "success",
|
||
message: `File written: ${filePath}`,
|
||
},
|
||
});
|
||
}
|
||
} else {
|
||
fileLogger.error(
|
||
`Fallback write failed with code ${code}: ${errorData}`,
|
||
);
|
||
if (!res.headersSent) {
|
||
res.status(500).json({
|
||
error: `Write failed: ${errorData}`,
|
||
toast: { type: "error", message: `Write failed: ${errorData}` },
|
||
});
|
||
}
|
||
}
|
||
});
|
||
|
||
stream.on("error", (streamErr) => {
|
||
fileLogger.error("Fallback write stream error:", streamErr);
|
||
if (!res.headersSent) {
|
||
res
|
||
.status(500)
|
||
.json({ error: `Write stream error: ${streamErr.message}` });
|
||
}
|
||
});
|
||
});
|
||
} catch (fallbackErr) {
|
||
fileLogger.error("Fallback method failed:", fallbackErr);
|
||
if (!res.headersSent) {
|
||
res
|
||
.status(500)
|
||
.json({ error: `All write methods failed: ${fallbackErr.message}` });
|
||
}
|
||
}
|
||
};
|
||
|
||
trySFTP();
|
||
});
|
||
|
||
app.post("/ssh/file_manager/ssh/uploadFile", async (req, res) => {
|
||
const {
|
||
sessionId,
|
||
path: filePath,
|
||
content,
|
||
fileName,
|
||
hostId,
|
||
userId,
|
||
} = req.body;
|
||
const sshConn = sshSessions[sessionId];
|
||
|
||
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 (!filePath || !fileName || content === undefined) {
|
||
return res
|
||
.status(400)
|
||
.json({ error: "File path, name, and content are required" });
|
||
}
|
||
|
||
sshConn.lastActive = Date.now();
|
||
|
||
const fullPath = filePath.endsWith("/")
|
||
? filePath + fileName
|
||
: filePath + "/" + fileName;
|
||
|
||
const trySFTP = () => {
|
||
try {
|
||
sshConn.client.sftp((err, sftp) => {
|
||
if (err) {
|
||
fileLogger.warn(
|
||
`SFTP failed, trying fallback method: ${err.message}`,
|
||
);
|
||
tryFallbackMethod();
|
||
return;
|
||
}
|
||
|
||
let fileBuffer;
|
||
try {
|
||
if (typeof content === "string") {
|
||
fileBuffer = Buffer.from(content, "utf8");
|
||
} else if (Buffer.isBuffer(content)) {
|
||
fileBuffer = content;
|
||
} else {
|
||
fileBuffer = Buffer.from(content);
|
||
}
|
||
} catch (bufferErr) {
|
||
fileLogger.error("Buffer conversion error:", bufferErr);
|
||
if (!res.headersSent) {
|
||
return res
|
||
.status(500)
|
||
.json({ error: "Invalid file content format" });
|
||
}
|
||
return;
|
||
}
|
||
|
||
const writeStream = sftp.createWriteStream(fullPath);
|
||
|
||
let hasError = false;
|
||
let hasFinished = false;
|
||
|
||
writeStream.on("error", (streamErr) => {
|
||
if (hasError || hasFinished) return;
|
||
hasError = true;
|
||
fileLogger.warn(
|
||
`SFTP write failed, trying fallback method: ${streamErr.message}`,
|
||
);
|
||
tryFallbackMethod();
|
||
});
|
||
|
||
writeStream.on("finish", () => {
|
||
if (hasError || hasFinished) return;
|
||
hasFinished = true;
|
||
if (!res.headersSent) {
|
||
res.json({
|
||
message: "File uploaded successfully",
|
||
path: fullPath,
|
||
toast: { type: "success", message: `File uploaded: ${fullPath}` },
|
||
});
|
||
}
|
||
});
|
||
|
||
writeStream.on("close", () => {
|
||
if (hasError || hasFinished) return;
|
||
hasFinished = true;
|
||
if (!res.headersSent) {
|
||
res.json({
|
||
message: "File uploaded successfully",
|
||
path: fullPath,
|
||
toast: { type: "success", message: `File uploaded: ${fullPath}` },
|
||
});
|
||
}
|
||
});
|
||
|
||
try {
|
||
writeStream.write(fileBuffer);
|
||
writeStream.end();
|
||
} catch (writeErr) {
|
||
if (hasError || hasFinished) return;
|
||
hasError = true;
|
||
fileLogger.warn(
|
||
`SFTP write operation failed, trying fallback method: ${writeErr.message}`,
|
||
);
|
||
tryFallbackMethod();
|
||
}
|
||
});
|
||
} catch (sftpErr) {
|
||
fileLogger.warn(
|
||
`SFTP connection error, trying fallback method: ${sftpErr.message}`,
|
||
);
|
||
tryFallbackMethod();
|
||
}
|
||
};
|
||
|
||
const tryFallbackMethod = () => {
|
||
try {
|
||
const base64Content = Buffer.from(content, "utf8").toString("base64");
|
||
const chunkSize = 1000000;
|
||
const chunks = [];
|
||
|
||
for (let i = 0; i < base64Content.length; i += chunkSize) {
|
||
chunks.push(base64Content.slice(i, i + chunkSize));
|
||
}
|
||
|
||
if (chunks.length === 1) {
|
||
const tempFile = `/tmp/upload_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||
const escapedTempFile = tempFile.replace(/'/g, "'\"'\"'");
|
||
const escapedPath = fullPath.replace(/'/g, "'\"'\"'");
|
||
|
||
const writeCommand = `echo '${chunks[0]}' | base64 -d > '${escapedPath}' && echo "SUCCESS"`;
|
||
|
||
sshConn.client.exec(writeCommand, (err, stream) => {
|
||
if (err) {
|
||
fileLogger.error("Fallback upload command failed:", err);
|
||
if (!res.headersSent) {
|
||
return res
|
||
.status(500)
|
||
.json({ error: `Upload failed: ${err.message}` });
|
||
}
|
||
return;
|
||
}
|
||
|
||
let outputData = "";
|
||
let errorData = "";
|
||
|
||
stream.on("data", (chunk: Buffer) => {
|
||
outputData += chunk.toString();
|
||
});
|
||
|
||
stream.stderr.on("data", (chunk: Buffer) => {
|
||
errorData += chunk.toString();
|
||
});
|
||
|
||
stream.on("close", (code) => {
|
||
if (outputData.includes("SUCCESS")) {
|
||
if (!res.headersSent) {
|
||
res.json({
|
||
message: "File uploaded successfully",
|
||
path: fullPath,
|
||
toast: {
|
||
type: "success",
|
||
message: `File uploaded: ${fullPath}`,
|
||
},
|
||
});
|
||
}
|
||
} else {
|
||
fileLogger.error(
|
||
`Fallback upload failed with code ${code}: ${errorData}`,
|
||
);
|
||
if (!res.headersSent) {
|
||
res.status(500).json({
|
||
error: `Upload failed: ${errorData}`,
|
||
toast: {
|
||
type: "error",
|
||
message: `Upload failed: ${errorData}`,
|
||
},
|
||
});
|
||
}
|
||
}
|
||
});
|
||
|
||
stream.on("error", (streamErr) => {
|
||
fileLogger.error("Fallback upload stream error:", streamErr);
|
||
if (!res.headersSent) {
|
||
res
|
||
.status(500)
|
||
.json({ error: `Upload stream error: ${streamErr.message}` });
|
||
}
|
||
});
|
||
});
|
||
} else {
|
||
const tempFile = `/tmp/upload_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||
const escapedTempFile = tempFile.replace(/'/g, "'\"'\"'");
|
||
const escapedPath = fullPath.replace(/'/g, "'\"'\"'");
|
||
|
||
let writeCommand = `> '${escapedPath}'`;
|
||
|
||
chunks.forEach((chunk, index) => {
|
||
writeCommand += ` && echo '${chunk}' | base64 -d >> '${escapedPath}'`;
|
||
});
|
||
|
||
writeCommand += ` && echo "SUCCESS"`;
|
||
|
||
sshConn.client.exec(writeCommand, (err, stream) => {
|
||
if (err) {
|
||
fileLogger.error("Chunked fallback upload failed:", err);
|
||
if (!res.headersSent) {
|
||
return res
|
||
.status(500)
|
||
.json({ error: `Chunked upload failed: ${err.message}` });
|
||
}
|
||
return;
|
||
}
|
||
|
||
let outputData = "";
|
||
let errorData = "";
|
||
|
||
stream.on("data", (chunk: Buffer) => {
|
||
outputData += chunk.toString();
|
||
});
|
||
|
||
stream.stderr.on("data", (chunk: Buffer) => {
|
||
errorData += chunk.toString();
|
||
});
|
||
|
||
stream.on("close", (code) => {
|
||
if (outputData.includes("SUCCESS")) {
|
||
if (!res.headersSent) {
|
||
res.json({
|
||
message: "File uploaded successfully",
|
||
path: fullPath,
|
||
toast: {
|
||
type: "success",
|
||
message: `File uploaded: ${fullPath}`,
|
||
},
|
||
});
|
||
}
|
||
} else {
|
||
fileLogger.error(
|
||
`Chunked fallback upload failed with code ${code}: ${errorData}`,
|
||
);
|
||
if (!res.headersSent) {
|
||
res.status(500).json({
|
||
error: `Chunked upload failed: ${errorData}`,
|
||
toast: {
|
||
type: "error",
|
||
message: `Chunked upload failed: ${errorData}`,
|
||
},
|
||
});
|
||
}
|
||
}
|
||
});
|
||
|
||
stream.on("error", (streamErr) => {
|
||
fileLogger.error(
|
||
"Chunked fallback upload stream error:",
|
||
streamErr,
|
||
);
|
||
if (!res.headersSent) {
|
||
res.status(500).json({
|
||
error: `Chunked upload stream error: ${streamErr.message}`,
|
||
});
|
||
}
|
||
});
|
||
});
|
||
}
|
||
} catch (fallbackErr) {
|
||
fileLogger.error("Fallback method failed:", fallbackErr);
|
||
if (!res.headersSent) {
|
||
res
|
||
.status(500)
|
||
.json({ error: `All upload methods failed: ${fallbackErr.message}` });
|
||
}
|
||
}
|
||
};
|
||
|
||
trySFTP();
|
||
});
|
||
|
||
app.post("/ssh/file_manager/ssh/createFile", async (req, res) => {
|
||
const {
|
||
sessionId,
|
||
path: filePath,
|
||
fileName,
|
||
content = "",
|
||
hostId,
|
||
userId,
|
||
} = req.body;
|
||
const sshConn = sshSessions[sessionId];
|
||
|
||
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 (!filePath || !fileName) {
|
||
return res.status(400).json({ error: "File path and name are required" });
|
||
}
|
||
|
||
sshConn.lastActive = Date.now();
|
||
|
||
const fullPath = filePath.endsWith("/")
|
||
? filePath + fileName
|
||
: filePath + "/" + fileName;
|
||
const escapedPath = fullPath.replace(/'/g, "'\"'\"'");
|
||
|
||
const createCommand = `touch '${escapedPath}' && echo "SUCCESS" && exit 0`;
|
||
|
||
sshConn.client.exec(createCommand, (err, stream) => {
|
||
if (err) {
|
||
fileLogger.error("SSH createFile error:", err);
|
||
if (!res.headersSent) {
|
||
return res.status(500).json({ error: err.message });
|
||
}
|
||
return;
|
||
}
|
||
|
||
let outputData = "";
|
||
let errorData = "";
|
||
|
||
stream.on("data", (chunk: Buffer) => {
|
||
outputData += chunk.toString();
|
||
});
|
||
|
||
stream.stderr.on("data", (chunk: Buffer) => {
|
||
errorData += chunk.toString();
|
||
|
||
if (chunk.toString().includes("Permission denied")) {
|
||
fileLogger.error(`Permission denied creating file: ${fullPath}`);
|
||
if (!res.headersSent) {
|
||
return res.status(403).json({
|
||
error: `Permission denied: Cannot create file ${fullPath}. Check directory permissions.`,
|
||
});
|
||
}
|
||
return;
|
||
}
|
||
});
|
||
|
||
stream.on("close", (code) => {
|
||
if (outputData.includes("SUCCESS")) {
|
||
if (!res.headersSent) {
|
||
res.json({
|
||
message: "File created successfully",
|
||
path: fullPath,
|
||
toast: { type: "success", message: `File created: ${fullPath}` },
|
||
});
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (code !== 0) {
|
||
fileLogger.error(
|
||
`SSH createFile command failed with code ${code}: ${errorData.replace(/\n/g, " ").trim()}`,
|
||
);
|
||
if (!res.headersSent) {
|
||
return res.status(500).json({
|
||
error: `Command failed: ${errorData}`,
|
||
toast: {
|
||
type: "error",
|
||
message: `File creation failed: ${errorData}`,
|
||
},
|
||
});
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (!res.headersSent) {
|
||
res.json({
|
||
message: "File created successfully",
|
||
path: fullPath,
|
||
toast: { type: "success", message: `File created: ${fullPath}` },
|
||
});
|
||
}
|
||
});
|
||
|
||
stream.on("error", (streamErr) => {
|
||
fileLogger.error("SSH createFile stream error:", streamErr);
|
||
if (!res.headersSent) {
|
||
res.status(500).json({ error: `Stream error: ${streamErr.message}` });
|
||
}
|
||
});
|
||
});
|
||
});
|
||
|
||
app.post("/ssh/file_manager/ssh/createFolder", async (req, res) => {
|
||
const { sessionId, path: folderPath, folderName, hostId, userId } = req.body;
|
||
const sshConn = sshSessions[sessionId];
|
||
|
||
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 (!folderPath || !folderName) {
|
||
return res.status(400).json({ error: "Folder path and name are required" });
|
||
}
|
||
|
||
sshConn.lastActive = Date.now();
|
||
|
||
const fullPath = folderPath.endsWith("/")
|
||
? folderPath + folderName
|
||
: folderPath + "/" + folderName;
|
||
const escapedPath = fullPath.replace(/'/g, "'\"'\"'");
|
||
|
||
const createCommand = `mkdir -p '${escapedPath}' && echo "SUCCESS" && exit 0`;
|
||
|
||
sshConn.client.exec(createCommand, (err, stream) => {
|
||
if (err) {
|
||
fileLogger.error("SSH createFolder error:", err);
|
||
if (!res.headersSent) {
|
||
return res.status(500).json({ error: err.message });
|
||
}
|
||
return;
|
||
}
|
||
|
||
let outputData = "";
|
||
let errorData = "";
|
||
|
||
stream.on("data", (chunk: Buffer) => {
|
||
outputData += chunk.toString();
|
||
});
|
||
|
||
stream.stderr.on("data", (chunk: Buffer) => {
|
||
errorData += chunk.toString();
|
||
|
||
if (chunk.toString().includes("Permission denied")) {
|
||
fileLogger.error(`Permission denied creating folder: ${fullPath}`);
|
||
if (!res.headersSent) {
|
||
return res.status(403).json({
|
||
error: `Permission denied: Cannot create folder ${fullPath}. Check directory permissions.`,
|
||
});
|
||
}
|
||
return;
|
||
}
|
||
});
|
||
|
||
stream.on("close", (code) => {
|
||
if (outputData.includes("SUCCESS")) {
|
||
if (!res.headersSent) {
|
||
res.json({
|
||
message: "Folder created successfully",
|
||
path: fullPath,
|
||
toast: { type: "success", message: `Folder created: ${fullPath}` },
|
||
});
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (code !== 0) {
|
||
fileLogger.error(
|
||
`SSH createFolder command failed with code ${code}: ${errorData.replace(/\n/g, " ").trim()}`,
|
||
);
|
||
if (!res.headersSent) {
|
||
return res.status(500).json({
|
||
error: `Command failed: ${errorData}`,
|
||
toast: {
|
||
type: "error",
|
||
message: `Folder creation failed: ${errorData}`,
|
||
},
|
||
});
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (!res.headersSent) {
|
||
res.json({
|
||
message: "Folder created successfully",
|
||
path: fullPath,
|
||
toast: { type: "success", message: `Folder created: ${fullPath}` },
|
||
});
|
||
}
|
||
});
|
||
|
||
stream.on("error", (streamErr) => {
|
||
fileLogger.error("SSH createFolder stream error:", streamErr);
|
||
if (!res.headersSent) {
|
||
res.status(500).json({ error: `Stream error: ${streamErr.message}` });
|
||
}
|
||
});
|
||
});
|
||
});
|
||
|
||
app.delete("/ssh/file_manager/ssh/deleteItem", async (req, res) => {
|
||
const { sessionId, path: itemPath, isDirectory, hostId, userId } = req.body;
|
||
const sshConn = sshSessions[sessionId];
|
||
|
||
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 (!itemPath) {
|
||
return res.status(400).json({ error: "Item path is required" });
|
||
}
|
||
|
||
sshConn.lastActive = Date.now();
|
||
const escapedPath = itemPath.replace(/'/g, "'\"'\"'");
|
||
|
||
const deleteCommand = isDirectory
|
||
? `rm -rf '${escapedPath}' && echo "SUCCESS" && exit 0`
|
||
: `rm -f '${escapedPath}' && echo "SUCCESS" && exit 0`;
|
||
|
||
sshConn.client.exec(deleteCommand, (err, stream) => {
|
||
if (err) {
|
||
fileLogger.error("SSH deleteItem error:", err);
|
||
if (!res.headersSent) {
|
||
return res.status(500).json({ error: err.message });
|
||
}
|
||
return;
|
||
}
|
||
|
||
let outputData = "";
|
||
let errorData = "";
|
||
|
||
stream.on("data", (chunk: Buffer) => {
|
||
outputData += chunk.toString();
|
||
});
|
||
|
||
stream.stderr.on("data", (chunk: Buffer) => {
|
||
errorData += chunk.toString();
|
||
|
||
if (chunk.toString().includes("Permission denied")) {
|
||
fileLogger.error(`Permission denied deleting: ${itemPath}`);
|
||
if (!res.headersSent) {
|
||
return res.status(403).json({
|
||
error: `Permission denied: Cannot delete ${itemPath}. Check file permissions.`,
|
||
});
|
||
}
|
||
return;
|
||
}
|
||
});
|
||
|
||
stream.on("close", (code) => {
|
||
if (outputData.includes("SUCCESS")) {
|
||
if (!res.headersSent) {
|
||
res.json({
|
||
message: "Item deleted successfully",
|
||
path: itemPath,
|
||
toast: {
|
||
type: "success",
|
||
message: `${isDirectory ? "Directory" : "File"} deleted: ${itemPath}`,
|
||
},
|
||
});
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (code !== 0) {
|
||
fileLogger.error(
|
||
`SSH deleteItem command failed with code ${code}: ${errorData.replace(/\n/g, " ").trim()}`,
|
||
);
|
||
if (!res.headersSent) {
|
||
return res.status(500).json({
|
||
error: `Command failed: ${errorData}`,
|
||
toast: { type: "error", message: `Delete failed: ${errorData}` },
|
||
});
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (!res.headersSent) {
|
||
res.json({
|
||
message: "Item deleted successfully",
|
||
path: itemPath,
|
||
toast: {
|
||
type: "success",
|
||
message: `${isDirectory ? "Directory" : "File"} deleted: ${itemPath}`,
|
||
},
|
||
});
|
||
}
|
||
});
|
||
|
||
stream.on("error", (streamErr) => {
|
||
fileLogger.error("SSH deleteItem stream error:", streamErr);
|
||
if (!res.headersSent) {
|
||
res.status(500).json({ error: `Stream error: ${streamErr.message}` });
|
||
}
|
||
});
|
||
});
|
||
});
|
||
|
||
app.put("/ssh/file_manager/ssh/renameItem", async (req, res) => {
|
||
const { sessionId, oldPath, newName, hostId, userId } = req.body;
|
||
const sshConn = sshSessions[sessionId];
|
||
|
||
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 (!oldPath || !newName) {
|
||
return res
|
||
.status(400)
|
||
.json({ error: "Old path and new name are required" });
|
||
}
|
||
|
||
sshConn.lastActive = Date.now();
|
||
|
||
const oldDir = oldPath.substring(0, oldPath.lastIndexOf("/") + 1);
|
||
const newPath = oldDir + newName;
|
||
const escapedOldPath = oldPath.replace(/'/g, "'\"'\"'");
|
||
const escapedNewPath = newPath.replace(/'/g, "'\"'\"'");
|
||
|
||
const renameCommand = `mv '${escapedOldPath}' '${escapedNewPath}' && echo "SUCCESS" && exit 0`;
|
||
|
||
sshConn.client.exec(renameCommand, (err, stream) => {
|
||
if (err) {
|
||
fileLogger.error("SSH renameItem error:", err);
|
||
if (!res.headersSent) {
|
||
return res.status(500).json({ error: err.message });
|
||
}
|
||
return;
|
||
}
|
||
|
||
let outputData = "";
|
||
let errorData = "";
|
||
|
||
stream.on("data", (chunk: Buffer) => {
|
||
outputData += chunk.toString();
|
||
});
|
||
|
||
stream.stderr.on("data", (chunk: Buffer) => {
|
||
errorData += chunk.toString();
|
||
|
||
if (chunk.toString().includes("Permission denied")) {
|
||
fileLogger.error(`Permission denied renaming: ${oldPath}`);
|
||
if (!res.headersSent) {
|
||
return res.status(403).json({
|
||
error: `Permission denied: Cannot rename ${oldPath}. Check file permissions.`,
|
||
});
|
||
}
|
||
return;
|
||
}
|
||
});
|
||
|
||
stream.on("close", (code) => {
|
||
if (outputData.includes("SUCCESS")) {
|
||
if (!res.headersSent) {
|
||
res.json({
|
||
message: "Item renamed successfully",
|
||
oldPath,
|
||
newPath,
|
||
toast: {
|
||
type: "success",
|
||
message: `Item renamed: ${oldPath} -> ${newPath}`,
|
||
},
|
||
});
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (code !== 0) {
|
||
fileLogger.error(
|
||
`SSH renameItem command failed with code ${code}: ${errorData.replace(/\n/g, " ").trim()}`,
|
||
);
|
||
if (!res.headersSent) {
|
||
return res.status(500).json({
|
||
error: `Command failed: ${errorData}`,
|
||
toast: { type: "error", message: `Rename failed: ${errorData}` },
|
||
});
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (!res.headersSent) {
|
||
res.json({
|
||
message: "Item renamed successfully",
|
||
oldPath,
|
||
newPath,
|
||
toast: {
|
||
type: "success",
|
||
message: `Item renamed: ${oldPath} -> ${newPath}`,
|
||
},
|
||
});
|
||
}
|
||
});
|
||
|
||
stream.on("error", (streamErr) => {
|
||
fileLogger.error("SSH renameItem stream error:", streamErr);
|
||
if (!res.headersSent) {
|
||
res.status(500).json({ error: `Stream error: ${streamErr.message}` });
|
||
}
|
||
});
|
||
});
|
||
});
|
||
|
||
// New API for moving files/folders across directories (for cut operation)
|
||
app.put("/ssh/file_manager/ssh/moveItem", async (req, res) => {
|
||
const { sessionId, oldPath, newPath, hostId, userId } = req.body;
|
||
const sshConn = sshSessions[sessionId];
|
||
|
||
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 (!oldPath || !newPath) {
|
||
return res
|
||
.status(400)
|
||
.json({ error: "Old path and new path are required" });
|
||
}
|
||
|
||
sshConn.lastActive = Date.now();
|
||
|
||
const escapedOldPath = oldPath.replace(/'/g, "'\"'\"'");
|
||
const escapedNewPath = newPath.replace(/'/g, "'\"'\"'");
|
||
|
||
const moveCommand = `mv '${escapedOldPath}' '${escapedNewPath}' && echo "SUCCESS" && exit 0`;
|
||
|
||
sshConn.client.exec(moveCommand, (err, stream) => {
|
||
if (err) {
|
||
fileLogger.error("SSH moveItem error:", err);
|
||
if (!res.headersSent) {
|
||
return res.status(500).json({ error: err.message });
|
||
}
|
||
return;
|
||
}
|
||
|
||
let outputData = "";
|
||
let errorData = "";
|
||
|
||
stream.on("data", (chunk: Buffer) => {
|
||
outputData += chunk.toString();
|
||
});
|
||
|
||
stream.stderr.on("data", (chunk: Buffer) => {
|
||
errorData += chunk.toString();
|
||
|
||
if (chunk.toString().includes("Permission denied")) {
|
||
fileLogger.error(`Permission denied moving: ${oldPath}`);
|
||
if (!res.headersSent) {
|
||
return res.status(403).json({
|
||
error: `Permission denied: Cannot move ${oldPath}. Check file permissions.`,
|
||
toast: {
|
||
type: "error",
|
||
message: `Permission denied: Cannot move ${oldPath}. Check file permissions.`,
|
||
},
|
||
});
|
||
}
|
||
return;
|
||
}
|
||
});
|
||
|
||
stream.on("close", (code) => {
|
||
if (outputData.includes("SUCCESS")) {
|
||
if (!res.headersSent) {
|
||
res.json({
|
||
message: "Item moved successfully",
|
||
oldPath,
|
||
newPath,
|
||
toast: {
|
||
type: "success",
|
||
message: `Item moved: ${oldPath} -> ${newPath}`,
|
||
},
|
||
});
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (code !== 0) {
|
||
fileLogger.error(
|
||
`SSH moveItem command failed with code ${code}: ${errorData.replace(/\n/g, " ").trim()}`,
|
||
);
|
||
if (!res.headersSent) {
|
||
return res.status(500).json({
|
||
error: `Command failed: ${errorData}`,
|
||
toast: { type: "error", message: `Move failed: ${errorData}` },
|
||
});
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (!res.headersSent) {
|
||
res.json({
|
||
message: "Item moved successfully",
|
||
oldPath,
|
||
newPath,
|
||
toast: {
|
||
type: "success",
|
||
message: `Item moved: ${oldPath} -> ${newPath}`,
|
||
},
|
||
});
|
||
}
|
||
});
|
||
|
||
stream.on("error", (streamErr) => {
|
||
fileLogger.error("SSH moveItem stream error:", streamErr);
|
||
if (!res.headersSent) {
|
||
res.status(500).json({ error: `Stream error: ${streamErr.message}` });
|
||
}
|
||
});
|
||
});
|
||
});
|
||
|
||
app.post("/ssh/file_manager/ssh/downloadFile", async (req, res) => {
|
||
const { sessionId, path: filePath, hostId, userId } = req.body;
|
||
|
||
if (!sessionId || !filePath) {
|
||
fileLogger.warn("Missing download parameters", {
|
||
operation: "file_download",
|
||
sessionId,
|
||
hasFilePath: !!filePath,
|
||
});
|
||
return res.status(400).json({ error: "Missing download parameters" });
|
||
}
|
||
|
||
const sshConn = sshSessions[sessionId];
|
||
if (!sshConn || !sshConn.isConnected) {
|
||
fileLogger.warn("SSH session not found or not connected for download", {
|
||
operation: "file_download",
|
||
sessionId,
|
||
isConnected: sshConn?.isConnected,
|
||
});
|
||
return res
|
||
.status(400)
|
||
.json({ error: "SSH session not found or not connected" });
|
||
}
|
||
|
||
sshConn.lastActive = Date.now();
|
||
scheduleSessionCleanup(sessionId);
|
||
|
||
// Use SFTP to read file for binary safety
|
||
sshConn.client.sftp((err, sftp) => {
|
||
if (err) {
|
||
fileLogger.error("SFTP connection failed for download:", err);
|
||
return res.status(500).json({ error: "SFTP connection failed" });
|
||
}
|
||
|
||
// Get file stats first to check if it's a regular file and get size
|
||
sftp.stat(filePath, (statErr, stats) => {
|
||
if (statErr) {
|
||
fileLogger.error("File stat failed for download:", statErr);
|
||
return res
|
||
.status(500)
|
||
.json({ error: `Cannot access file: ${statErr.message}` });
|
||
}
|
||
|
||
if (!stats.isFile()) {
|
||
fileLogger.warn("Attempted to download non-file", {
|
||
operation: "file_download",
|
||
sessionId,
|
||
filePath,
|
||
isFile: stats.isFile(),
|
||
isDirectory: stats.isDirectory(),
|
||
});
|
||
return res
|
||
.status(400)
|
||
.json({ error: "Cannot download directories or special files" });
|
||
}
|
||
|
||
// Check file size (limit to 100MB for safety)
|
||
const MAX_FILE_SIZE = 100 * 1024 * 1024; // 100MB
|
||
if (stats.size > MAX_FILE_SIZE) {
|
||
fileLogger.warn("File too large for download", {
|
||
operation: "file_download",
|
||
sessionId,
|
||
filePath,
|
||
fileSize: stats.size,
|
||
maxSize: MAX_FILE_SIZE,
|
||
});
|
||
return res.status(400).json({
|
||
error: `File too large. Maximum size is ${MAX_FILE_SIZE / 1024 / 1024}MB, file is ${(stats.size / 1024 / 1024).toFixed(2)}MB`,
|
||
});
|
||
}
|
||
|
||
// Read file content
|
||
sftp.readFile(filePath, (readErr, data) => {
|
||
if (readErr) {
|
||
fileLogger.error("File read failed for download:", readErr);
|
||
return res
|
||
.status(500)
|
||
.json({ error: `Failed to read file: ${readErr.message}` });
|
||
}
|
||
|
||
// Convert to base64 for safe transport
|
||
const base64Content = data.toString("base64");
|
||
const fileName = filePath.split("/").pop() || "download";
|
||
|
||
fileLogger.success("File downloaded successfully", {
|
||
operation: "file_download",
|
||
sessionId,
|
||
filePath,
|
||
fileName,
|
||
fileSize: stats.size,
|
||
hostId,
|
||
userId,
|
||
});
|
||
|
||
res.json({
|
||
content: base64Content,
|
||
fileName: fileName,
|
||
size: stats.size,
|
||
mimeType: getMimeType(fileName),
|
||
path: filePath,
|
||
});
|
||
});
|
||
});
|
||
});
|
||
});
|
||
|
||
// Copy SSH file/directory
|
||
app.post("/ssh/file_manager/ssh/copyItem", async (req, res) => {
|
||
const { sessionId, sourcePath, targetDir, hostId, userId } = req.body;
|
||
|
||
if (!sessionId || !sourcePath || !targetDir) {
|
||
return res.status(400).json({ error: "Missing required parameters" });
|
||
}
|
||
|
||
const sshConn = sshSessions[sessionId];
|
||
if (!sshConn || !sshConn.isConnected) {
|
||
return res
|
||
.status(400)
|
||
.json({ error: "SSH session not found or not connected" });
|
||
}
|
||
|
||
sshConn.lastActive = Date.now();
|
||
scheduleSessionCleanup(sessionId);
|
||
|
||
try {
|
||
// Extract source name
|
||
const sourceName = sourcePath.split("/").pop() || "copied_item";
|
||
|
||
// First check if source file exists
|
||
const escapedSourceForCheck = sourcePath.replace(/'/g, "'\"'\"'");
|
||
const checkExistsCommand = `test -e '${escapedSourceForCheck}'`;
|
||
const checkExists = await new Promise<boolean>((resolve) => {
|
||
sshConn.client.exec(checkExistsCommand, (err, stream) => {
|
||
if (err) {
|
||
fileLogger.error("File existence check error:", err);
|
||
resolve(false);
|
||
return;
|
||
}
|
||
|
||
stream.on("close", (code) => {
|
||
fileLogger.info("File existence check completed", {
|
||
sourcePath,
|
||
exists: code === 0,
|
||
});
|
||
resolve(code === 0);
|
||
});
|
||
|
||
stream.on("error", () => resolve(false));
|
||
});
|
||
});
|
||
|
||
if (!checkExists) {
|
||
return res.status(404).json({
|
||
error: `Source file not found: ${sourcePath}`,
|
||
toast: {
|
||
type: "error",
|
||
message: `Source file not found: ${sourceName}`,
|
||
},
|
||
});
|
||
}
|
||
|
||
// Use timestamp for uniqueness
|
||
const timestamp = Date.now().toString().slice(-8);
|
||
const nameWithoutExt = sourceName.includes(".")
|
||
? sourceName.substring(0, sourceName.lastIndexOf("."))
|
||
: sourceName;
|
||
const extension = sourceName.includes(".")
|
||
? sourceName.substring(sourceName.lastIndexOf("."))
|
||
: "";
|
||
|
||
// Always use timestamp suffix to ensure uniqueness without SSH calls
|
||
const uniqueName = `${nameWithoutExt}_copy_${timestamp}${extension}`;
|
||
|
||
fileLogger.info("Using timestamp-based unique name", {
|
||
originalName: sourceName,
|
||
uniqueName,
|
||
});
|
||
const targetPath = `${targetDir}/${uniqueName}`;
|
||
|
||
// Escape paths for shell commands
|
||
const escapedSource = sourcePath.replace(/'/g, "'\"'\"'");
|
||
const escapedTarget = targetPath.replace(/'/g, "'\"'\"'");
|
||
|
||
// Use cp with explicit flags to avoid hanging on prompts
|
||
// -f: force overwrite without prompting
|
||
// -r: recursive for directories
|
||
// -p: preserve timestamps, permissions
|
||
const copyCommand = `cp -fpr '${escapedSource}' '${escapedTarget}' 2>&1`;
|
||
|
||
fileLogger.info("Starting file copy operation", {
|
||
operation: "file_copy_start",
|
||
sessionId,
|
||
sourcePath,
|
||
targetPath,
|
||
uniqueName,
|
||
command: copyCommand.substring(0, 200) + "...", // Log truncated command
|
||
});
|
||
|
||
// Add timeout to prevent hanging
|
||
const commandTimeout = setTimeout(() => {
|
||
fileLogger.error("Copy command timed out after 20 seconds", {
|
||
sourcePath,
|
||
targetPath,
|
||
command: copyCommand,
|
||
});
|
||
if (!res.headersSent) {
|
||
res.status(500).json({
|
||
error: "Copy operation timed out",
|
||
toast: {
|
||
type: "error",
|
||
message:
|
||
"Copy operation timed out. SSH connection may be unstable.",
|
||
},
|
||
});
|
||
}
|
||
}, 20000); // 20 second timeout for better responsiveness
|
||
|
||
sshConn.client.exec(copyCommand, (err, stream) => {
|
||
if (err) {
|
||
clearTimeout(commandTimeout);
|
||
fileLogger.error("SSH copyItem error:", err);
|
||
if (!res.headersSent) {
|
||
return res.status(500).json({ error: err.message });
|
||
}
|
||
return;
|
||
}
|
||
|
||
let errorData = "";
|
||
let stdoutData = "";
|
||
|
||
// Monitor both stdout and stderr
|
||
stream.on("data", (data: Buffer) => {
|
||
const output = data.toString();
|
||
stdoutData += output;
|
||
fileLogger.info("Copy command stdout", {
|
||
output: output.substring(0, 200),
|
||
});
|
||
});
|
||
|
||
stream.stderr.on("data", (data: Buffer) => {
|
||
const output = data.toString();
|
||
errorData += output;
|
||
fileLogger.info("Copy command stderr", {
|
||
output: output.substring(0, 200),
|
||
});
|
||
});
|
||
|
||
stream.on("close", (code) => {
|
||
clearTimeout(commandTimeout);
|
||
fileLogger.info("Copy command completed", {
|
||
code,
|
||
errorData,
|
||
hasError: errorData.length > 0,
|
||
});
|
||
|
||
if (code !== 0) {
|
||
const fullErrorInfo =
|
||
errorData || stdoutData || "No error message available";
|
||
fileLogger.error(`SSH copyItem command failed with code ${code}`, {
|
||
operation: "file_copy_failed",
|
||
sessionId,
|
||
sourcePath,
|
||
targetPath,
|
||
command: copyCommand,
|
||
exitCode: code,
|
||
errorData,
|
||
stdoutData,
|
||
fullErrorInfo,
|
||
});
|
||
if (!res.headersSent) {
|
||
return res.status(500).json({
|
||
error: `Copy failed: ${fullErrorInfo}`,
|
||
toast: {
|
||
type: "error",
|
||
message: `Copy failed: ${fullErrorInfo}`,
|
||
},
|
||
debug: {
|
||
sourcePath,
|
||
targetPath,
|
||
exitCode: code,
|
||
command: copyCommand,
|
||
},
|
||
});
|
||
}
|
||
return;
|
||
}
|
||
|
||
fileLogger.success("Item copied successfully", {
|
||
operation: "file_copy",
|
||
sessionId,
|
||
sourcePath,
|
||
targetPath,
|
||
uniqueName,
|
||
hostId,
|
||
userId,
|
||
});
|
||
|
||
if (!res.headersSent) {
|
||
res.json({
|
||
message: "Item copied successfully",
|
||
sourcePath,
|
||
targetPath,
|
||
uniqueName,
|
||
toast: {
|
||
type: "success",
|
||
message: `Successfully copied to: ${uniqueName}`,
|
||
},
|
||
});
|
||
}
|
||
});
|
||
|
||
stream.on("error", (streamErr) => {
|
||
clearTimeout(commandTimeout);
|
||
fileLogger.error("SSH copyItem stream error:", streamErr);
|
||
if (!res.headersSent) {
|
||
res.status(500).json({ error: `Stream error: ${streamErr.message}` });
|
||
}
|
||
});
|
||
});
|
||
} catch (error: any) {
|
||
fileLogger.error("Copy operation error:", error);
|
||
res.status(500).json({ error: error.message });
|
||
}
|
||
});
|
||
|
||
// Helper function to determine MIME type based on file extension
|
||
function getMimeType(fileName: string): string {
|
||
const ext = fileName.split(".").pop()?.toLowerCase();
|
||
const mimeTypes: Record<string, string> = {
|
||
txt: "text/plain",
|
||
json: "application/json",
|
||
js: "text/javascript",
|
||
html: "text/html",
|
||
css: "text/css",
|
||
png: "image/png",
|
||
jpg: "image/jpeg",
|
||
jpeg: "image/jpeg",
|
||
gif: "image/gif",
|
||
pdf: "application/pdf",
|
||
zip: "application/zip",
|
||
tar: "application/x-tar",
|
||
gz: "application/gzip",
|
||
};
|
||
return mimeTypes[ext || ""] || "application/octet-stream";
|
||
}
|
||
|
||
process.on("SIGINT", () => {
|
||
Object.keys(sshSessions).forEach(cleanupSession);
|
||
process.exit(0);
|
||
});
|
||
|
||
process.on("SIGTERM", () => {
|
||
Object.keys(sshSessions).forEach(cleanupSession);
|
||
process.exit(0);
|
||
});
|
||
|
||
// 执行可执行文件
|
||
app.post("/ssh/file_manager/ssh/executeFile", async (req, res) => {
|
||
const { sessionId, filePath, hostId, userId } = req.body;
|
||
const sshConn = sshSessions[sessionId];
|
||
|
||
if (!sshConn || !sshConn.isConnected) {
|
||
fileLogger.error(
|
||
"SSH connection not found or not connected for executeFile",
|
||
{
|
||
operation: "execute_file",
|
||
sessionId,
|
||
hasConnection: !!sshConn,
|
||
isConnected: sshConn?.isConnected,
|
||
},
|
||
);
|
||
return res.status(400).json({ error: "SSH connection not available" });
|
||
}
|
||
|
||
if (!filePath) {
|
||
return res.status(400).json({ error: "File path is required" });
|
||
}
|
||
|
||
const escapedPath = filePath.replace(/'/g, "'\"'\"'");
|
||
|
||
// 检查文件是否存在且可执行
|
||
const checkCommand = `test -x '${escapedPath}' && echo "EXECUTABLE" || echo "NOT_EXECUTABLE"`;
|
||
|
||
sshConn.client.exec(checkCommand, (checkErr, checkStream) => {
|
||
if (checkErr) {
|
||
fileLogger.error("SSH executeFile check error:", checkErr);
|
||
return res
|
||
.status(500)
|
||
.json({ error: "Failed to check file executability" });
|
||
}
|
||
|
||
let checkResult = "";
|
||
checkStream.on("data", (data) => {
|
||
checkResult += data.toString();
|
||
});
|
||
|
||
checkStream.on("close", (code) => {
|
||
if (!checkResult.includes("EXECUTABLE")) {
|
||
return res.status(400).json({ error: "File is not executable" });
|
||
}
|
||
|
||
// 执行文件
|
||
const executeCommand = `cd "$(dirname '${escapedPath}')" && '${escapedPath}' 2>&1; echo "EXIT_CODE:$?"`;
|
||
|
||
fileLogger.info("Executing file", {
|
||
operation: "execute_file",
|
||
sessionId,
|
||
filePath,
|
||
command: executeCommand.substring(0, 100) + "...",
|
||
});
|
||
|
||
sshConn.client.exec(executeCommand, (err, stream) => {
|
||
if (err) {
|
||
fileLogger.error("SSH executeFile error:", err);
|
||
return res.status(500).json({ error: "Failed to execute file" });
|
||
}
|
||
|
||
let output = "";
|
||
let errorOutput = "";
|
||
|
||
stream.on("data", (data) => {
|
||
output += data.toString();
|
||
});
|
||
|
||
stream.stderr.on("data", (data) => {
|
||
errorOutput += data.toString();
|
||
});
|
||
|
||
stream.on("close", (code) => {
|
||
// 从输出中提取退出代码
|
||
const exitCodeMatch = output.match(/EXIT_CODE:(\d+)$/);
|
||
const actualExitCode = exitCodeMatch
|
||
? parseInt(exitCodeMatch[1])
|
||
: code;
|
||
const cleanOutput = output.replace(/EXIT_CODE:\d+$/, "").trim();
|
||
|
||
fileLogger.info("File execution completed", {
|
||
operation: "execute_file",
|
||
sessionId,
|
||
filePath,
|
||
exitCode: actualExitCode,
|
||
outputLength: cleanOutput.length,
|
||
errorLength: errorOutput.length,
|
||
});
|
||
|
||
res.json({
|
||
success: true,
|
||
exitCode: actualExitCode,
|
||
output: cleanOutput,
|
||
error: errorOutput,
|
||
timestamp: new Date().toISOString(),
|
||
});
|
||
});
|
||
|
||
stream.on("error", (streamErr) => {
|
||
fileLogger.error("SSH executeFile stream error:", streamErr);
|
||
if (!res.headersSent) {
|
||
res.status(500).json({ error: "Execution stream error" });
|
||
}
|
||
});
|
||
});
|
||
});
|
||
});
|
||
});
|
||
|
||
const PORT = 8084;
|
||
app.listen(PORT, () => {
|
||
fileLogger.success("File Manager API server started", {
|
||
operation: "server_start",
|
||
port: PORT,
|
||
});
|
||
});
|