Files
Termix/src/backend/ssh/file-manager.ts

2161 lines
62 KiB
TypeScript

import express from "express";
import cors from "cors";
import { Client as SSHClient } from "ssh2";
import { getDb } 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 { SimpleDBOps } from "../utils/simple-db-ops.js";
import { AuthManager } from "../utils/auth-manager.js";
// Executable file detection utility function
function isExecutableFile(permissions: string, fileName: string): boolean {
// Check execute permission bits (user, group, other)
const hasExecutePermission =
permissions[3] === "x" || permissions[6] === "x" || permissions[9] === "x";
// Common script file extensions
const scriptExtensions = [
".sh",
".py",
".pl",
".rb",
".js",
".php",
".bash",
".zsh",
".fish",
];
const hasScriptExtension = scriptExtensions.some((ext) =>
fileName.toLowerCase().endsWith(ext),
);
// Common compiled executable files (no extension or specific extensions)
const executableExtensions = [".bin", ".exe", ".out"];
const hasExecutableExtension = executableExtensions.some((ext) =>
fileName.toLowerCase().endsWith(ext),
);
// Files with no extension and execute permission are usually executable files
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: "1gb" }));
app.use(express.urlencoded({ limit: "1gb", extended: true }));
app.use(express.raw({ limit: "5gb", type: "application/octet-stream" }));
// Initialize AuthManager and add authentication middleware
const authManager = AuthManager.getInstance();
app.use(authManager.createAuthMiddleware());
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);
// Increase timeout to 30 minutes of inactivity
session.timeout = setTimeout(() => {
cleanupSession(sessionId);
}, 30 * 60 * 1000); // 30 minutes - increased from 10 minutes
}
}
app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
const {
sessionId,
hostId,
ip,
port,
username,
password,
sshKey,
keyPassword,
authType,
credentialId,
} = req.body;
// Use authenticated user ID from middleware
const userId = (req as any).userId;
if (!userId) {
fileLogger.error("SSH connection rejected: no authenticated user", {
operation: "file_connect_auth",
sessionId,
});
return res.status(401).json({ error: "Authentication required" });
}
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 SimpleDBOps.select(
getDb()
.select()
.from(sshCredentials)
.where(
and(
eq(sshCredentials.id, credentialId),
eq(sshCredentials.userId, userId),
),
),
"ssh_credentials",
userId,
);
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-etm@openssh.com", "hmac-sha2-512-etm@openssh.com", "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 });
});
// SSH keepalive endpoint - extends session timeout and verifies connection
app.post("/ssh/file_manager/ssh/keepalive", (req, res) => {
const { sessionId } = req.body;
if (!sessionId) {
return res.status(400).json({ error: "Session ID is required" });
}
const session = sshSessions[sessionId];
if (!session || !session.isConnected) {
return res.status(400).json({
error: "SSH session not found or not connected",
connected: false
});
}
// Update last active time and reschedule cleanup
session.lastActive = Date.now();
scheduleSessionCleanup(sessionId);
res.json({
status: "success",
connected: true,
message: "Session keepalive successful",
lastActive: session.lastActive
});
});
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);
// Date may occupy 3 parts (month day time) or (month day year)
let dateStr = "";
let nameStartIndex = 8;
if (parts[5] && parts[6] && parts[7]) {
// Regular format: month day time/year
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;
// Parse symbolic link target
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, // Directories don't show size
modified: dateStr,
permissions,
owner,
group,
linkTarget, // Symbolic link target
path: `${sshPath.endsWith("/") ? sshPath : sshPath + "/"}${actualName}`, // Add full path
executable:
!isDirectory && !isLink
? isExecutableFile(permissions, actualName)
: false, // Detect executable files
});
}
}
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();
// Support large file reading - increased limit for better compatibility
const MAX_READ_SIZE = 500 * 1024 * 1024; // 500MB - much more reasonable 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) {
// Check if it's a file not found error (case-insensitive)
const errorLower = sizeErrorData.toLowerCase();
const isFileNotFound = errorLower.includes("no such file or directory") ||
errorLower.includes("cannot access") ||
errorLower.includes("not found") ||
errorLower.includes("resource not found");
fileLogger.error(`File size check failed: ${sizeErrorData}`);
return res
.status(isFileNotFound ? 404 : 500)
.json({
error: `Cannot check file size: ${sizeErrorData}`,
fileNotFound: isFileNotFound
});
}
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()}`,
);
// Check if it's a "file not found" error
const isFileNotFound =
errorData.includes("No such file or directory") ||
errorData.includes("cannot access") ||
errorData.includes("not found");
return res
.status(isFileNotFound ? 404 : 500)
.json({
error: `Command failed: ${errorData}`,
fileNotFound: isFileNotFound
});
}
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" });
}
// Update last active time and extend keepalive for large file operations
sshConn.lastActive = Date.now();
// For large files, extend the keepalive interval to prevent connection drops
const contentSize = typeof content === 'string' ? Buffer.byteLength(content, 'utf8') : content.length;
if (contentSize > 10 * 1024 * 1024) { // 10MB threshold
fileLogger.info("Large file upload detected, extending SSH keepalive", {
operation: "file_upload",
sessionId,
fileName,
fileSize: contentSize,
});
// Extend keepalive interval for large files
// Note: SSH2 client doesn't expose config directly, but we can set keepalive
sshConn.client.setKeepAlive(true, 10000); // 10 seconds
}
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}`,
{
operation: "file_upload",
sessionId,
fileName,
fileSize: contentSize,
error: 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`;
// Add timeout for move operation
const commandTimeout = setTimeout(() => {
if (!res.headersSent) {
res.status(408).json({
error: "Move operation timed out. SSH connection may be unstable.",
toast: {
type: "error",
message: "Move operation timed out. SSH connection may be unstable.",
},
});
}
}, 60000); // 60 second timeout for move operations
sshConn.client.exec(moveCommand, (err, stream) => {
if (err) {
clearTimeout(commandTimeout);
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) => {
clearTimeout(commandTimeout);
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) => {
clearTimeout(commandTimeout);
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" });
}
// Support large file downloads - increased limit for better compatibility
const MAX_FILE_SIZE = 5 * 1024 * 1024 * 1024; // 5GB - reasonable for SSH file operations
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";
// Linus principle: simplify - generate unique name directly without complex checks
const timestamp = Date.now().toString().slice(-8);
const uniqueName = `${sourceName}_copy_${timestamp}`;
const targetPath = `${targetDir}/${uniqueName}`;
fileLogger.info("Starting copy operation", {
originalName: sourceName,
uniqueName,
sourcePath,
targetPath,
sessionId,
});
// Escape paths for shell commands
const escapedSource = sourcePath.replace(/'/g, "'\"'\"'");
const escapedTarget = targetPath.replace(/'/g, "'\"'\"'");
// Linus principle: simplify - use basic cp command for reliability
// Just copy the file without complex flags that might cause issues
const copyCommand = `cp '${escapedSource}' '${escapedTarget}' && echo "COPY_SUCCESS"`;
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 60 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.",
},
});
}
}, 60000); // 60 second timeout for large files
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;
}
// Verify copy completion with COPY_SUCCESS marker or exit code 0
const copySuccessful = stdoutData.includes("COPY_SUCCESS") || code === 0;
if (copySuccessful) {
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}`,
},
});
}
} else {
fileLogger.warn("Copy completed but without success confirmation", {
operation: "file_copy_uncertain",
sessionId,
sourcePath,
targetPath,
code,
stdoutData: stdoutData.substring(0, 200),
});
if (!res.headersSent) {
res.json({
message: "Copy may have completed",
sourcePath,
targetPath,
uniqueName,
toast: {
type: "warning",
message: `Copy completed but verification uncertain for: ${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);
});
// Execute executable file
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, "'\"'\"'");
// Check if file exists and is executable
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" });
}
// Execute file
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) => {
// Extract exit code from output
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 = 30004;
app.listen(PORT, async () => {
fileLogger.success("File Manager API server started", {
operation: "server_start",
port: PORT,
});
// Initialize AuthManager for JWT verification
try {
await authManager.initialize();
fileLogger.info("AuthManager initialized for file manager", {
operation: "auth_init",
});
} catch (err) {
fileLogger.error("Failed to initialize AuthManager", err, {
operation: "auth_init_error",
});
}
});