mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 14:45:24 +00:00
fix: finish 2.9.0 plugin moves and get every check green
This commit is contained in:
1 parent
8c1e02af89
commit
6489b62ece
335 files changed
+29878
-17749
No files matched your search
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* The SSH auth types core always provides: password, key, credential, agent
|
||||
* and none. Everything else is a plugin (or, in 2.9.0, a legacy provider).
|
||||
* and none. Everything else is a plugin, through ctx.auth.
|
||||
*/
|
||||
|
||||
import ssh2 from "ssh2";
|
||||
|
||||
@@ -152,7 +152,7 @@ export interface SshAuthField {
|
||||
export interface SshAuthProvider {
|
||||
/** Stored in ssh_data.auth_type. */
|
||||
type: string;
|
||||
/** "core" for built-ins and the legacy providers A8 keeps in core. */
|
||||
/** "core" for the built-ins, otherwise the registering plugin's id. */
|
||||
pluginId: string;
|
||||
labelKey: string;
|
||||
descriptionKey?: string;
|
||||
|
||||
@@ -1,541 +0,0 @@
|
||||
import type { Express } from "express";
|
||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||
import { fileLogger } from "../../utils/logger.js";
|
||||
import { execChannel, type SSHSession } from "./session.js";
|
||||
|
||||
type FileActionRoutesDeps = {
|
||||
sshSessions: Record<string, SSHSession>;
|
||||
scheduleSessionCleanup: (sessionId: string) => void;
|
||||
verifySessionOwnership: (session: SSHSession, userId: string) => boolean;
|
||||
};
|
||||
|
||||
export function registerFileActionRoutes(
|
||||
app: Express,
|
||||
{
|
||||
sshSessions,
|
||||
scheduleSessionCleanup,
|
||||
verifySessionOwnership,
|
||||
}: FileActionRoutesDeps,
|
||||
): void {
|
||||
/**
|
||||
* @openapi
|
||||
* /ssh/file_manager/ssh/copyItem:
|
||||
* post:
|
||||
* summary: Copy a file or directory
|
||||
* description: Copies a file or directory on the remote host.
|
||||
* tags:
|
||||
* - File Manager
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* sessionId:
|
||||
* type: string
|
||||
* sourcePath:
|
||||
* type: string
|
||||
* targetDir:
|
||||
* type: string
|
||||
* hostId:
|
||||
* type: integer
|
||||
* userId:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Item copied successfully.
|
||||
* 400:
|
||||
* description: Missing required parameters or SSH connection not established.
|
||||
* 500:
|
||||
* description: Failed to copy item.
|
||||
*/
|
||||
app.post("/ssh/file_manager/ssh/copyItem", async (req, res) => {
|
||||
const { sessionId, sourcePath, targetDir, hostId } = req.body;
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
|
||||
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" });
|
||||
}
|
||||
|
||||
if (!verifySessionOwnership(sshConn, userId)) {
|
||||
return res.status(403).json({ error: "Session access denied" });
|
||||
}
|
||||
|
||||
sshConn.lastActive = Date.now();
|
||||
scheduleSessionCleanup(sessionId);
|
||||
|
||||
const sourceName = sourcePath.split("/").pop() || "copied_item";
|
||||
|
||||
const timestamp = Date.now().toString().slice(-8);
|
||||
const uniqueName = `${sourceName}_copy_${timestamp}`;
|
||||
const targetPath = `${targetDir}/${uniqueName}`;
|
||||
|
||||
const escapedSource = sourcePath.replace(/'/g, "'\"'\"'");
|
||||
const escapedTarget = targetPath.replace(/'/g, "'\"'\"'");
|
||||
|
||||
const copyCommand = `cp '${escapedSource}' '${escapedTarget}' && echo "COPY_SUCCESS"`;
|
||||
|
||||
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);
|
||||
|
||||
execChannel(sshConn, 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 = "";
|
||||
|
||||
stream.on("data", (data: Buffer) => {
|
||||
const output = data.toString();
|
||||
stdoutData += output;
|
||||
stream.stderr.on("data", (data: Buffer) => {
|
||||
const output = data.toString();
|
||||
errorData += output;
|
||||
});
|
||||
|
||||
stream.on("close", (code) => {
|
||||
clearTimeout(commandTimeout);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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}` });
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /ssh/file_manager/ssh/executeFile:
|
||||
* post:
|
||||
* summary: Execute a file
|
||||
* description: Executes a file on the remote host.
|
||||
* tags:
|
||||
* - File Manager
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* sessionId:
|
||||
* type: string
|
||||
* filePath:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: File execution result.
|
||||
* 400:
|
||||
* description: Missing required parameters or SSH connection not available.
|
||||
* 500:
|
||||
* description: Failed to execute file.
|
||||
*/
|
||||
app.post("/ssh/file_manager/ssh/executeFile", async (req, res) => {
|
||||
const { sessionId, filePath } = req.body;
|
||||
const sshConn = sshSessions[sessionId];
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
|
||||
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 (!verifySessionOwnership(sshConn, userId)) {
|
||||
return res.status(403).json({ error: "Session access denied" });
|
||||
}
|
||||
|
||||
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"`;
|
||||
|
||||
execChannel(sshConn, 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", () => {
|
||||
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:$?"`;
|
||||
|
||||
execChannel(sshConn, 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" });
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /ssh/file_manager/ssh/changePermissions:
|
||||
* post:
|
||||
* summary: Change file permissions
|
||||
* description: Changes the permissions of a file on the remote host.
|
||||
* tags:
|
||||
* - File Manager
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* sessionId:
|
||||
* type: string
|
||||
* path:
|
||||
* type: string
|
||||
* permissions:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Permissions changed successfully.
|
||||
* 400:
|
||||
* description: Missing required parameters or SSH connection not available.
|
||||
* 408:
|
||||
* description: Permission change timed out.
|
||||
* 500:
|
||||
* description: Failed to change permissions.
|
||||
*/
|
||||
app.post("/ssh/file_manager/ssh/changePermissions", async (req, res) => {
|
||||
const { sessionId, path, permissions } = req.body;
|
||||
const sshConn = sshSessions[sessionId];
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
|
||||
if (!sshConn || !sshConn.isConnected) {
|
||||
fileLogger.error(
|
||||
"SSH connection not found or not connected for changePermissions",
|
||||
{
|
||||
operation: "change_permissions",
|
||||
sessionId,
|
||||
hasConnection: !!sshConn,
|
||||
isConnected: sshConn?.isConnected,
|
||||
},
|
||||
);
|
||||
return res.status(400).json({ error: "SSH connection not available" });
|
||||
}
|
||||
|
||||
if (!verifySessionOwnership(sshConn, userId)) {
|
||||
return res.status(403).json({ error: "Session access denied" });
|
||||
}
|
||||
|
||||
if (!path) {
|
||||
return res.status(400).json({ error: "File path is required" });
|
||||
}
|
||||
|
||||
if (!permissions || !/^\d{3,4}$/.test(permissions)) {
|
||||
return res.status(400).json({
|
||||
error: "Valid permissions required (e.g., 755, 644)",
|
||||
});
|
||||
}
|
||||
|
||||
sshConn.lastActive = Date.now();
|
||||
scheduleSessionCleanup(sessionId);
|
||||
|
||||
const octalPerms = permissions.slice(-3);
|
||||
const escapedPath = path.replace(/'/g, "'\"'\"'");
|
||||
const command = `chmod ${octalPerms} '${escapedPath}' && echo "SUCCESS"`;
|
||||
|
||||
fileLogger.info("Changing file permissions", {
|
||||
operation: "change_permissions",
|
||||
sessionId,
|
||||
path,
|
||||
permissions: octalPerms,
|
||||
});
|
||||
|
||||
const commandTimeout = setTimeout(() => {
|
||||
if (!res.headersSent) {
|
||||
fileLogger.error("changePermissions command timeout", {
|
||||
operation: "change_permissions",
|
||||
sessionId,
|
||||
path,
|
||||
permissions: octalPerms,
|
||||
});
|
||||
res.status(408).json({
|
||||
error: "Permission change timed out. SSH connection may be unstable.",
|
||||
});
|
||||
}
|
||||
}, 10000);
|
||||
|
||||
execChannel(sshConn, command, (err, stream) => {
|
||||
if (err) {
|
||||
clearTimeout(commandTimeout);
|
||||
fileLogger.error("SSH changePermissions exec error:", err, {
|
||||
operation: "change_permissions",
|
||||
sessionId,
|
||||
path,
|
||||
permissions: octalPerms,
|
||||
});
|
||||
if (!res.headersSent) {
|
||||
return res
|
||||
.status(500)
|
||||
.json({ error: "Failed to change permissions" });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let outputData = "";
|
||||
let errorOutput = "";
|
||||
|
||||
stream.on("data", (chunk: Buffer) => {
|
||||
outputData += chunk.toString();
|
||||
});
|
||||
|
||||
stream.stderr.on("data", (data: Buffer) => {
|
||||
errorOutput += data.toString();
|
||||
});
|
||||
|
||||
stream.on("close", (code) => {
|
||||
clearTimeout(commandTimeout);
|
||||
|
||||
if (outputData.includes("SUCCESS")) {
|
||||
fileLogger.success("File permissions changed successfully", {
|
||||
operation: "change_permissions",
|
||||
sessionId,
|
||||
path,
|
||||
permissions: octalPerms,
|
||||
});
|
||||
|
||||
if (!res.headersSent) {
|
||||
res.json({
|
||||
success: true,
|
||||
message: "Permissions changed successfully",
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (code !== 0) {
|
||||
fileLogger.error("chmod command failed", {
|
||||
operation: "change_permissions",
|
||||
sessionId,
|
||||
path,
|
||||
permissions: octalPerms,
|
||||
exitCode: code,
|
||||
error: errorOutput,
|
||||
});
|
||||
if (!res.headersSent) {
|
||||
return res.status(500).json({
|
||||
error: errorOutput || "Failed to change permissions",
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
fileLogger.success("File permissions changed successfully", {
|
||||
operation: "change_permissions",
|
||||
sessionId,
|
||||
path,
|
||||
permissions: octalPerms,
|
||||
});
|
||||
|
||||
if (!res.headersSent) {
|
||||
res.json({
|
||||
success: true,
|
||||
message: "Permissions changed successfully",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
stream.on("error", (streamErr) => {
|
||||
clearTimeout(commandTimeout);
|
||||
fileLogger.error("SSH changePermissions stream error:", streamErr, {
|
||||
operation: "change_permissions",
|
||||
sessionId,
|
||||
path,
|
||||
permissions: octalPerms,
|
||||
});
|
||||
if (!res.headersSent) {
|
||||
res
|
||||
.status(500)
|
||||
.json({ error: "Stream error while changing permissions" });
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,72 +0,0 @@
|
||||
export interface DirectTransferEndpoint {
|
||||
host: string;
|
||||
port: number;
|
||||
username: string;
|
||||
}
|
||||
|
||||
export const DIRECT_TRANSFER_MIN_IMPROVEMENT = 0.2;
|
||||
export const DIRECT_TRANSFER_MIN_BYTES = 32 * 1024 * 1024;
|
||||
|
||||
export function shouldBenchmarkDirectTransfer(totalBytes: number): boolean {
|
||||
return totalBytes >= DIRECT_TRANSFER_MIN_BYTES;
|
||||
}
|
||||
|
||||
export function quoteShell(value: string): string {
|
||||
return `'${value.replace(/'/g, `'"'"'`)}'`;
|
||||
}
|
||||
|
||||
function formatHost(host: string): string {
|
||||
return host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
|
||||
}
|
||||
|
||||
export function buildDirectSshCommand(
|
||||
endpoint: DirectTransferEndpoint,
|
||||
): string {
|
||||
const target = `${endpoint.username}@${formatHost(endpoint.host)}`;
|
||||
return [
|
||||
"ssh",
|
||||
"-o BatchMode=yes",
|
||||
"-o ConnectTimeout=5",
|
||||
"-o StrictHostKeyChecking=yes",
|
||||
`-p ${endpoint.port}`,
|
||||
quoteShell(target),
|
||||
].join(" ");
|
||||
}
|
||||
|
||||
export function buildDirectProbeCommand(
|
||||
endpoint: DirectTransferEndpoint,
|
||||
): string {
|
||||
return `${buildDirectSshCommand(endpoint)} ${quoteShell("command -v rsync >/dev/null")}`;
|
||||
}
|
||||
|
||||
export function buildDirectRsyncCommand(
|
||||
endpoint: DirectTransferEndpoint,
|
||||
sourcePaths: string[],
|
||||
destPath: string,
|
||||
destIsDirectory: boolean,
|
||||
): string {
|
||||
const sshTransport = buildDirectSshCommand(endpoint).replace(/ '[^']+'$/, "");
|
||||
const targetHost = `${endpoint.username}@${formatHost(endpoint.host)}`;
|
||||
const targetPath = destIsDirectory
|
||||
? `${destPath.replace(/\/+$/, "") || "/"}/`
|
||||
: destPath;
|
||||
const sources = sourcePaths.map(quoteShell).join(" ");
|
||||
const target = quoteShell(`${targetHost}:${targetPath}`);
|
||||
|
||||
return [
|
||||
"rsync -a --partial --append-verify --protect-args --info=progress2",
|
||||
`-e ${quoteShell(sshTransport)}`,
|
||||
"--",
|
||||
sources,
|
||||
target,
|
||||
].join(" ");
|
||||
}
|
||||
|
||||
export function shouldUseDirectTransfer(
|
||||
directMs: number,
|
||||
relayMs: number,
|
||||
minImprovement = DIRECT_TRANSFER_MIN_IMPROVEMENT,
|
||||
): boolean {
|
||||
if (directMs <= 0 || relayMs <= 0) return false;
|
||||
return directMs <= relayMs * (1 - minImprovement);
|
||||
}
|
||||
@@ -1,356 +0,0 @@
|
||||
import type { Express } from "express";
|
||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||
import { fileLogger } from "../../utils/logger.js";
|
||||
import { getMimeType } from "./utils.js";
|
||||
import { execChannel, getSessionSftp, type SSHSession } from "./session.js";
|
||||
|
||||
type FileDownloadRoutesDeps = {
|
||||
sshSessions: Record<string, SSHSession>;
|
||||
scheduleSessionCleanup: (sessionId: string) => void;
|
||||
verifySessionOwnership: (session: SSHSession, userId: string) => boolean;
|
||||
};
|
||||
|
||||
function escapeSingleQuotes(path: string): string {
|
||||
return path.replace(/'/g, "'\"'\"'");
|
||||
}
|
||||
|
||||
function downloadViaExec(
|
||||
session: SSHSession,
|
||||
filePath: string,
|
||||
): Promise<Buffer> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const escaped = escapeSingleQuotes(filePath);
|
||||
execChannel(session, `cat '${escaped}'`, (err, stream) => {
|
||||
if (err) return reject(err);
|
||||
const chunks: Buffer[] = [];
|
||||
let stderr = "";
|
||||
stream.on("data", (chunk: Buffer) => chunks.push(chunk));
|
||||
stream.stderr.on("data", (chunk: Buffer) => {
|
||||
stderr += chunk.toString();
|
||||
});
|
||||
stream.on("close", (code: number) => {
|
||||
if (code !== 0) {
|
||||
return reject(
|
||||
new Error(stderr.trim() || `cat exited with code ${code}`),
|
||||
);
|
||||
}
|
||||
resolve(Buffer.concat(chunks));
|
||||
});
|
||||
stream.on("error", reject);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function registerFileDownloadRoutes(
|
||||
app: Express,
|
||||
{
|
||||
sshSessions,
|
||||
scheduleSessionCleanup,
|
||||
verifySessionOwnership,
|
||||
}: FileDownloadRoutesDeps,
|
||||
): void {
|
||||
/**
|
||||
* @openapi
|
||||
* /ssh/file_manager/ssh/downloadFile:
|
||||
* post:
|
||||
* summary: Download a file
|
||||
* description: Downloads a file from the remote host. Uses SCP legacy mode (cat over exec) when the host has scpLegacy enabled, otherwise uses SFTP.
|
||||
* tags:
|
||||
* - File Manager
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* sessionId:
|
||||
* type: string
|
||||
* path:
|
||||
* type: string
|
||||
* hostId:
|
||||
* type: integer
|
||||
* userId:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The file content.
|
||||
* 400:
|
||||
* description: Missing required parameters or file too large.
|
||||
* 500:
|
||||
* description: Failed to download file.
|
||||
*/
|
||||
app.post("/ssh/file_manager/ssh/downloadFile", async (req, res) => {
|
||||
const { sessionId, path: filePath, hostId } = req.body;
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const downloadStartTime = Date.now();
|
||||
|
||||
if (!sessionId || !filePath) {
|
||||
fileLogger.warn("Missing download parameters", {
|
||||
operation: "file_download",
|
||||
sessionId,
|
||||
hasFilePath: !!filePath,
|
||||
});
|
||||
return res.status(400).json({ error: "Missing download parameters" });
|
||||
}
|
||||
|
||||
fileLogger.info("File download started", {
|
||||
operation: "file_download_start",
|
||||
sessionId,
|
||||
userId,
|
||||
path: filePath,
|
||||
});
|
||||
|
||||
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" });
|
||||
}
|
||||
|
||||
if (!verifySessionOwnership(sshConn, userId)) {
|
||||
return res.status(403).json({ error: "Session access denied" });
|
||||
}
|
||||
|
||||
sshConn.lastActive = Date.now();
|
||||
scheduleSessionCleanup(sessionId);
|
||||
|
||||
if (sshConn.scpLegacy) {
|
||||
fileLogger.info(
|
||||
"Downloading file via legacy exec/cat (SCP legacy mode)",
|
||||
{
|
||||
operation: "file_download_legacy",
|
||||
sessionId,
|
||||
userId,
|
||||
path: filePath,
|
||||
},
|
||||
);
|
||||
|
||||
try {
|
||||
const data = await downloadViaExec(sshConn, filePath);
|
||||
const fileName = filePath.split("/").pop() || "download";
|
||||
fileLogger.success("File download completed (legacy mode)", {
|
||||
operation: "file_download_complete",
|
||||
sessionId,
|
||||
userId,
|
||||
hostId,
|
||||
path: filePath,
|
||||
bytes: data.length,
|
||||
duration: Date.now() - downloadStartTime,
|
||||
});
|
||||
return res.json({
|
||||
content: data.toString("base64"),
|
||||
fileName,
|
||||
size: data.length,
|
||||
mimeType: getMimeType(fileName),
|
||||
path: filePath,
|
||||
});
|
||||
} catch (err) {
|
||||
fileLogger.error("Legacy exec/cat download failed:", err);
|
||||
return res.status(500).json({
|
||||
error: `Failed to download file: ${(err as Error).message}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fileLogger.info("Opening SFTP channel", {
|
||||
operation: "file_sftp_open",
|
||||
sessionId,
|
||||
userId,
|
||||
path: filePath,
|
||||
});
|
||||
|
||||
getSessionSftp(sshConn)
|
||||
.then((sftp) => {
|
||||
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" });
|
||||
}
|
||||
|
||||
const MAX_FILE_SIZE = 5 * 1024 * 1024 * 1024;
|
||||
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`,
|
||||
});
|
||||
}
|
||||
|
||||
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}` });
|
||||
}
|
||||
|
||||
const base64Content = data.toString("base64");
|
||||
const fileName = filePath.split("/").pop() || "download";
|
||||
fileLogger.success("File download completed", {
|
||||
operation: "file_download_complete",
|
||||
sessionId,
|
||||
userId,
|
||||
hostId,
|
||||
path: filePath,
|
||||
bytes: stats.size,
|
||||
duration: Date.now() - downloadStartTime,
|
||||
});
|
||||
|
||||
res.json({
|
||||
content: base64Content,
|
||||
fileName: fileName,
|
||||
size: stats.size,
|
||||
mimeType: getMimeType(fileName),
|
||||
path: filePath,
|
||||
});
|
||||
});
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
fileLogger.error("SFTP connection failed for download:", err);
|
||||
return res.status(500).json({ error: "SFTP connection failed" });
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /ssh/file_manager/ssh/downloadFileStream:
|
||||
* post:
|
||||
* summary: Stream-download a file
|
||||
* description: Downloads a file as a binary stream. Uses SCP legacy mode (cat over exec) when the host has scpLegacy enabled, otherwise uses SFTP.
|
||||
* tags:
|
||||
* - File Manager
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* sessionId:
|
||||
* type: string
|
||||
* path:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Binary file stream.
|
||||
* 400:
|
||||
* description: Missing required parameters.
|
||||
* 500:
|
||||
* description: Download failed.
|
||||
*/
|
||||
app.post("/ssh/file_manager/ssh/downloadFileStream", async (req, res) => {
|
||||
const { sessionId, path: filePath } = req.body;
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
|
||||
if (!sessionId || !filePath) {
|
||||
return res.status(400).json({ error: "Missing download parameters" });
|
||||
}
|
||||
|
||||
const sshConn = sshSessions[sessionId];
|
||||
if (!sshConn?.isConnected) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "SSH session not found or not connected" });
|
||||
}
|
||||
if (!verifySessionOwnership(sshConn, userId)) {
|
||||
return res.status(403).json({ error: "Session access denied" });
|
||||
}
|
||||
|
||||
sshConn.lastActive = Date.now();
|
||||
|
||||
const fileName = filePath.split("/").pop() || "download";
|
||||
res.setHeader("Content-Type", "application/octet-stream");
|
||||
res.setHeader(
|
||||
"Content-Disposition",
|
||||
`attachment; filename="${encodeURIComponent(fileName)}"`,
|
||||
);
|
||||
|
||||
if (sshConn.scpLegacy) {
|
||||
fileLogger.info("Streaming file via legacy exec/cat (SCP legacy mode)", {
|
||||
operation: "file_download_stream_legacy",
|
||||
sessionId,
|
||||
userId,
|
||||
path: filePath,
|
||||
});
|
||||
|
||||
const escaped = escapeSingleQuotes(filePath);
|
||||
execChannel(sshConn, `cat '${escaped}'`, (err, stream) => {
|
||||
if (err) {
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({ error: `Download failed: ${err.message}` });
|
||||
}
|
||||
return;
|
||||
}
|
||||
stream.on("error", (streamErr: Error) => {
|
||||
if (!res.headersSent) {
|
||||
res
|
||||
.status(500)
|
||||
.json({ error: `Download failed: ${streamErr.message}` });
|
||||
} else {
|
||||
res.destroy();
|
||||
}
|
||||
});
|
||||
stream.pipe(res);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const sftp = await getSessionSftp(sshConn);
|
||||
const stats = await new Promise<{ size: number; isFile: () => boolean }>(
|
||||
(resolve, reject) => {
|
||||
sftp.stat(filePath, (err, s) => (err ? reject(err) : resolve(s)));
|
||||
},
|
||||
);
|
||||
|
||||
if (!stats.isFile()) {
|
||||
return res.status(400).json({ error: "Cannot download directories" });
|
||||
}
|
||||
|
||||
res.setHeader("Content-Length", String(stats.size));
|
||||
|
||||
const readStream = sftp.createReadStream(filePath);
|
||||
readStream.on("error", (err) => {
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({ error: `Download failed: ${err.message}` });
|
||||
} else {
|
||||
res.destroy();
|
||||
}
|
||||
});
|
||||
readStream.pipe(res);
|
||||
} catch (err) {
|
||||
if (!res.headersSent) {
|
||||
res
|
||||
.status(500)
|
||||
.json({ error: `Download failed: ${(err as Error).message}` });
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,467 +0,0 @@
|
||||
import { getErrorMessage } from "../../utils/error-message.js";
|
||||
import type { Express } from "express";
|
||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||
import { fileLogger } from "../../utils/logger.js";
|
||||
import { execChannel, getSessionSftp, type SSHSession } from "./session.js";
|
||||
import {
|
||||
formatMtime,
|
||||
isExecutableFile,
|
||||
modeToPermissions,
|
||||
parseLsDateToTimestamp,
|
||||
} from "./utils.js";
|
||||
|
||||
type FileListingRoutesDeps = {
|
||||
sshSessions: Record<string, SSHSession>;
|
||||
activeListRequests: Record<string, boolean>;
|
||||
verifySessionOwnership: (session: SSHSession, userId: string) => boolean;
|
||||
};
|
||||
|
||||
export function registerFileListingRoutes(
|
||||
app: Express,
|
||||
{
|
||||
sshSessions,
|
||||
activeListRequests,
|
||||
verifySessionOwnership,
|
||||
}: FileListingRoutesDeps,
|
||||
): void {
|
||||
/**
|
||||
* @openapi
|
||||
* /ssh/file_manager/ssh/listFiles:
|
||||
* get:
|
||||
* summary: List files in a directory
|
||||
* description: Lists the files and directories in a given path on the remote host.
|
||||
* tags:
|
||||
* - File Manager
|
||||
* parameters:
|
||||
* - in: query
|
||||
* name: sessionId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* - in: query
|
||||
* name: path
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: A list of files and directories.
|
||||
* 400:
|
||||
* description: Session ID is required or SSH connection not established.
|
||||
* 500:
|
||||
* description: Failed to list files.
|
||||
*/
|
||||
app.get("/ssh/file_manager/ssh/listFiles", (req, res) => {
|
||||
const sessionId = req.query.sessionId as string;
|
||||
const sshConn = sshSessions[sessionId];
|
||||
const sshPath = (req.query.path as string) || "/";
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
|
||||
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 (!verifySessionOwnership(sshConn, userId)) {
|
||||
return res.status(403).json({ error: "Session access denied" });
|
||||
}
|
||||
|
||||
// Drop concurrent requests for the same session+path — each would open
|
||||
// a new SSH channel and can exceed the server's per-connection channel limit.
|
||||
const listKey = `${sessionId}:${sshPath}`;
|
||||
if (activeListRequests[listKey]) {
|
||||
return res
|
||||
.status(409)
|
||||
.json({ error: "List request already in progress" });
|
||||
}
|
||||
activeListRequests[listKey] = true;
|
||||
res.on("finish", () => {
|
||||
delete activeListRequests[listKey];
|
||||
});
|
||||
|
||||
sshConn.lastActive = Date.now();
|
||||
sshConn.activeOperations++;
|
||||
const trySFTP = () => {
|
||||
try {
|
||||
fileLogger.info("Opening SFTP channel", {
|
||||
operation: "file_sftp_open",
|
||||
sessionId,
|
||||
userId,
|
||||
path: sshPath,
|
||||
});
|
||||
getSessionSftp(sshConn)
|
||||
.then((sftp) => {
|
||||
sftp.readdir(sshPath, (readdirErr, list) => {
|
||||
if (readdirErr) {
|
||||
fileLogger.warn(
|
||||
`SFTP readdir failed, trying fallback: ${readdirErr.message}`,
|
||||
);
|
||||
tryFallbackMethod();
|
||||
return;
|
||||
}
|
||||
|
||||
const symlinks: Array<{ index: number; path: string }> = [];
|
||||
const files: Array<{
|
||||
name: string;
|
||||
type: string;
|
||||
size: number | undefined;
|
||||
modified: string;
|
||||
modifiedTimestamp: number;
|
||||
permissions: string;
|
||||
owner: string;
|
||||
group: string;
|
||||
linkTarget: string | undefined;
|
||||
path: string;
|
||||
executable: boolean;
|
||||
}> = [];
|
||||
|
||||
for (const entry of list) {
|
||||
if (entry.filename === "." || entry.filename === "..") continue;
|
||||
|
||||
const attrs = entry.attrs;
|
||||
const permissions = modeToPermissions(attrs.mode);
|
||||
const isDirectory = attrs.isDirectory();
|
||||
const isLink = attrs.isSymbolicLink();
|
||||
|
||||
const fileEntry = {
|
||||
name: entry.filename,
|
||||
type: isDirectory ? "directory" : isLink ? "link" : "file",
|
||||
size: isDirectory ? undefined : attrs.size,
|
||||
modified: formatMtime(attrs.mtime),
|
||||
modifiedTimestamp: attrs.mtime,
|
||||
permissions,
|
||||
owner: String(attrs.uid),
|
||||
group: String(attrs.gid),
|
||||
linkTarget: undefined as string | undefined,
|
||||
path: `${sshPath.endsWith("/") ? sshPath : sshPath + "/"}${entry.filename}`,
|
||||
executable:
|
||||
!isDirectory && !isLink
|
||||
? isExecutableFile(permissions, entry.filename)
|
||||
: false,
|
||||
};
|
||||
|
||||
if (isLink) {
|
||||
symlinks.push({ index: files.length, path: fileEntry.path });
|
||||
}
|
||||
|
||||
files.push(fileEntry);
|
||||
}
|
||||
|
||||
if (symlinks.length === 0) {
|
||||
sshConn.activeOperations--;
|
||||
return res.json({ files, path: sshPath });
|
||||
}
|
||||
|
||||
let resolved = 0;
|
||||
let responded = false;
|
||||
|
||||
const sendResponse = () => {
|
||||
if (responded) return;
|
||||
responded = true;
|
||||
sshConn.activeOperations--;
|
||||
res.json({ files, path: sshPath });
|
||||
};
|
||||
|
||||
const readlinkTimeout = setTimeout(sendResponse, 5000);
|
||||
|
||||
for (const link of symlinks) {
|
||||
sftp.readlink(link.path, (linkErr, target) => {
|
||||
resolved++;
|
||||
if (!linkErr && target) {
|
||||
files[link.index].linkTarget = target;
|
||||
}
|
||||
if (resolved === symlinks.length) {
|
||||
clearTimeout(readlinkTimeout);
|
||||
sendResponse();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
})
|
||||
.catch((err: Error) => {
|
||||
fileLogger.warn(
|
||||
`SFTP failed for listFiles, trying fallback: ${err.message}`,
|
||||
);
|
||||
const isChannelFailure =
|
||||
err.message.toLowerCase().includes("channel open failure") ||
|
||||
err.message.toLowerCase().includes("open failed");
|
||||
if (isChannelFailure) {
|
||||
sshConn.isConnected = false;
|
||||
sshConn.sftp = undefined;
|
||||
}
|
||||
tryFallbackMethod();
|
||||
});
|
||||
} catch (sftpErr: unknown) {
|
||||
const errMsg = getErrorMessage(sftpErr);
|
||||
fileLogger.warn(`SFTP connection error, trying fallback: ${errMsg}`);
|
||||
tryFallbackMethod();
|
||||
}
|
||||
};
|
||||
|
||||
const tryFallbackMethod = () => {
|
||||
if (!sshConn?.isConnected) {
|
||||
sshConn.activeOperations--;
|
||||
return res
|
||||
.status(503)
|
||||
.json({ error: "SSH session disconnected", disconnected: true });
|
||||
}
|
||||
try {
|
||||
const escapedPath = sshPath.replace(/'/g, "'\"'\"'");
|
||||
execChannel(
|
||||
sshConn,
|
||||
`command ls -la --color=never '${escapedPath}'`,
|
||||
(err, stream) => {
|
||||
if (err) {
|
||||
sshConn.activeOperations--;
|
||||
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) {
|
||||
const isPermissionDenied =
|
||||
errorData.toLowerCase().includes("permission denied") ||
|
||||
errorData.toLowerCase().includes("access denied");
|
||||
|
||||
if (isPermissionDenied) {
|
||||
if (sshConn.sudoPassword) {
|
||||
fileLogger.info(
|
||||
`Permission denied for listFiles, retrying with sudo: ${sshPath}`,
|
||||
);
|
||||
tryWithSudo();
|
||||
return;
|
||||
}
|
||||
|
||||
sshConn.activeOperations--;
|
||||
fileLogger.warn(
|
||||
`Permission denied for listFiles, sudo required: ${sshPath}`,
|
||||
);
|
||||
return res.status(403).json({
|
||||
error: `Permission denied: Cannot access ${sshPath}`,
|
||||
needsSudo: true,
|
||||
path: sshPath,
|
||||
});
|
||||
}
|
||||
|
||||
sshConn.activeOperations--;
|
||||
fileLogger.error(
|
||||
`SSH listFiles command failed with code ${code}: ${errorData.replace(/\n/g, " ").trim()}`,
|
||||
);
|
||||
return res
|
||||
.status(500)
|
||||
.json({ error: `Command failed: ${errorData}` });
|
||||
}
|
||||
sshConn.activeOperations--;
|
||||
|
||||
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 owner = parts[2];
|
||||
const group = parts[3];
|
||||
const size = parseInt(parts[4], 10);
|
||||
|
||||
let dateStr = "";
|
||||
const 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,
|
||||
modifiedTimestamp: parseLsDateToTimestamp(dateStr),
|
||||
permissions,
|
||||
owner,
|
||||
group,
|
||||
linkTarget,
|
||||
path: `${sshPath.endsWith("/") ? sshPath : sshPath + "/"}${actualName}`,
|
||||
executable:
|
||||
!isDirectory && !isLink
|
||||
? isExecutableFile(permissions, actualName)
|
||||
: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ files, path: sshPath });
|
||||
});
|
||||
},
|
||||
);
|
||||
} catch (execErr: unknown) {
|
||||
sshConn.activeOperations--;
|
||||
const errMsg = getErrorMessage(execErr);
|
||||
fileLogger.error(`Fallback listFiles exec failed: ${errMsg}`);
|
||||
if (!res.headersSent) {
|
||||
return res.status(500).json({ error: errMsg });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const tryWithSudo = () => {
|
||||
try {
|
||||
const escapedPath = sshPath.replace(/'/g, "'\"'\"'");
|
||||
const escapedPassword = sshConn.sudoPassword!.replace(/'/g, "'\"'\"'");
|
||||
const sudoCommand = `echo '${escapedPassword}' | sudo -S /bin/ls -la --color=never '${escapedPath}' 2>&1`;
|
||||
|
||||
execChannel(sshConn, sudoCommand, (err, stream) => {
|
||||
if (err) {
|
||||
sshConn.activeOperations--;
|
||||
fileLogger.error("SSH sudo 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) => {
|
||||
sshConn.activeOperations--;
|
||||
|
||||
data = data.replace(/\[sudo\] password for .+?:\s*/g, "");
|
||||
|
||||
if (
|
||||
data.toLowerCase().includes("sorry, try again") ||
|
||||
data.toLowerCase().includes("incorrect password") ||
|
||||
errorData.toLowerCase().includes("sorry, try again")
|
||||
) {
|
||||
sshConn.sudoPassword = undefined;
|
||||
return res.status(403).json({
|
||||
error: "Sudo authentication failed. Please try again.",
|
||||
needsSudo: true,
|
||||
sudoFailed: true,
|
||||
path: sshPath,
|
||||
});
|
||||
}
|
||||
|
||||
if (code !== 0 && !data.trim()) {
|
||||
fileLogger.error(
|
||||
`SSH sudo listFiles failed with code ${code}: ${errorData.replace(/\n/g, " ").trim()}`,
|
||||
);
|
||||
return res
|
||||
.status(500)
|
||||
.json({ error: `Sudo command failed: ${errorData || data}` });
|
||||
}
|
||||
|
||||
const lines = data.split("\n").filter((line) => line.trim());
|
||||
const files: Array<{
|
||||
name: string;
|
||||
type: string;
|
||||
size: number | undefined;
|
||||
modified: string;
|
||||
modifiedTimestamp: number;
|
||||
permissions: string;
|
||||
owner: string;
|
||||
group: string;
|
||||
linkTarget: string | undefined;
|
||||
path: string;
|
||||
executable: boolean;
|
||||
}> = [];
|
||||
|
||||
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 owner = parts[2];
|
||||
const group = parts[3];
|
||||
const size = parseInt(parts[4], 10);
|
||||
|
||||
let dateStr = "";
|
||||
const 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,
|
||||
modifiedTimestamp: parseLsDateToTimestamp(dateStr),
|
||||
permissions,
|
||||
owner,
|
||||
group,
|
||||
linkTarget,
|
||||
path: `${sshPath.endsWith("/") ? sshPath : sshPath + "/"}${actualName}`,
|
||||
executable:
|
||||
!isDirectory && !isLink
|
||||
? isExecutableFile(permissions, actualName)
|
||||
: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ files, path: sshPath });
|
||||
});
|
||||
});
|
||||
} catch (execErr: unknown) {
|
||||
sshConn.activeOperations--;
|
||||
const errMsg = getErrorMessage(execErr);
|
||||
fileLogger.error(`Sudo listFiles exec failed: ${errMsg}`);
|
||||
if (!res.headersSent) {
|
||||
return res.status(500).json({ error: errMsg });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
trySFTP();
|
||||
});
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
import { isWindowsSftpPath, sftpPathToLocalPath } from "../transfer-paths.js";
|
||||
|
||||
export interface DeleteCommand {
|
||||
command: string;
|
||||
commandWithSuccess: string;
|
||||
}
|
||||
|
||||
function quotePosixPath(path: string): string {
|
||||
return `'${path.replace(/'/g, "'\"'\"'")}'`;
|
||||
}
|
||||
|
||||
function quotePowerShellLiteral(value: string): string {
|
||||
return `'${value.replace(/'/g, "''")}'`;
|
||||
}
|
||||
|
||||
export function buildDeleteCommand(
|
||||
itemPath: string,
|
||||
isDirectory: boolean,
|
||||
): DeleteCommand {
|
||||
if (isWindowsSftpPath(itemPath)) {
|
||||
const path = quotePowerShellLiteral(sftpPathToLocalPath(itemPath));
|
||||
const command = isDirectory
|
||||
? `Remove-Item -LiteralPath ${path} -Recurse -Force -ErrorAction Stop`
|
||||
: `Remove-Item -LiteralPath ${path} -Force -ErrorAction Stop`;
|
||||
|
||||
return {
|
||||
command,
|
||||
commandWithSuccess: `${command}; if ($?) { Write-Output "SUCCESS" }`,
|
||||
};
|
||||
}
|
||||
|
||||
const path = quotePosixPath(itemPath);
|
||||
const command = isDirectory ? `rm -rf ${path}` : `rm -f ${path}`;
|
||||
|
||||
return {
|
||||
command,
|
||||
commandWithSuccess: `${command} && echo "SUCCESS"`,
|
||||
};
|
||||
}
|
||||
@@ -1,913 +0,0 @@
|
||||
import type { Express } from "express";
|
||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||
import { fileLogger } from "../../utils/logger.js";
|
||||
import {
|
||||
execChannel,
|
||||
execWithSudo,
|
||||
getSessionSftp,
|
||||
type SSHSession,
|
||||
} from "./session.js";
|
||||
import { ensureDirectoryTreeSftp } from "./transfer-sftp-dir.js";
|
||||
import { buildDeleteCommand } from "./operation-commands.js";
|
||||
import {
|
||||
emptyTrash,
|
||||
listTrash,
|
||||
moveToTrash,
|
||||
permanentlyDeleteTrashItem,
|
||||
restoreTrashItem,
|
||||
} from "./trash-service.js";
|
||||
import {
|
||||
createCurrentSettingsRepository,
|
||||
getCurrentSettingValue,
|
||||
} from "../../database/repositories/factory.js";
|
||||
import { PermissionManager } from "../../utils/permission-manager.js";
|
||||
|
||||
type FileOperationRoutesDeps = {
|
||||
sshSessions: Record<string, SSHSession>;
|
||||
verifySessionOwnership: (session: SSHSession, userId: string) => boolean;
|
||||
};
|
||||
|
||||
export function registerFileOperationRoutes(
|
||||
app: Express,
|
||||
{ sshSessions, verifySessionOwnership }: FileOperationRoutesDeps,
|
||||
): void {
|
||||
const permissionManager = PermissionManager.getInstance();
|
||||
const getTrashRetentionDays = () => {
|
||||
try {
|
||||
const value = Number(
|
||||
getCurrentSettingValue("file_manager_trash_retention_days"),
|
||||
);
|
||||
return Number.isInteger(value) && value >= 1 && value <= 3650 ? value : 7;
|
||||
} catch {
|
||||
return 7;
|
||||
}
|
||||
};
|
||||
|
||||
async function ownedSession(
|
||||
req: AuthenticatedRequest,
|
||||
res: import("express").Response,
|
||||
) {
|
||||
const sessionId = String(req.body?.sessionId ?? req.query?.sessionId ?? "");
|
||||
const session = sshSessions[sessionId];
|
||||
if (!sessionId || !session?.isConnected) {
|
||||
res.status(400).json({ error: "SSH connection not established" });
|
||||
return null;
|
||||
}
|
||||
if (!verifySessionOwnership(session, req.userId)) {
|
||||
res.status(403).json({ error: "Session access denied" });
|
||||
return null;
|
||||
}
|
||||
session.lastActive = Date.now();
|
||||
return session;
|
||||
}
|
||||
|
||||
app.get("/ssh/file_manager/ssh/trash", async (req, res) => {
|
||||
const session = await ownedSession(
|
||||
req as unknown as AuthenticatedRequest,
|
||||
res,
|
||||
);
|
||||
if (!session) return;
|
||||
try {
|
||||
res.json({
|
||||
items: await listTrash(
|
||||
await getSessionSftp(session),
|
||||
getTrashRetentionDays(),
|
||||
),
|
||||
retentionDays: getTrashRetentionDays(),
|
||||
canManageRetention: await permissionManager.isAdmin(
|
||||
(req as AuthenticatedRequest).userId,
|
||||
),
|
||||
});
|
||||
} catch (error) {
|
||||
fileLogger.error("Failed to list trash", error);
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/ssh/file_manager/ssh/trash/:id/restore", async (req, res) => {
|
||||
const session = await ownedSession(
|
||||
req as unknown as AuthenticatedRequest,
|
||||
res,
|
||||
);
|
||||
if (!session) return;
|
||||
try {
|
||||
res.json({
|
||||
item: await restoreTrashItem(
|
||||
await getSessionSftp(session),
|
||||
req.params.id,
|
||||
),
|
||||
});
|
||||
} catch (error) {
|
||||
const message = (error as Error).message;
|
||||
res
|
||||
.status(message.includes("already exists") ? 409 : 500)
|
||||
.json({ error: message });
|
||||
}
|
||||
});
|
||||
|
||||
app.delete("/ssh/file_manager/ssh/trash/:id", async (req, res) => {
|
||||
const session = await ownedSession(
|
||||
req as unknown as AuthenticatedRequest,
|
||||
res,
|
||||
);
|
||||
if (!session) return;
|
||||
try {
|
||||
await permanentlyDeleteTrashItem(
|
||||
await getSessionSftp(session),
|
||||
req.params.id,
|
||||
);
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
app.delete("/ssh/file_manager/ssh/trash", async (req, res) => {
|
||||
const session = await ownedSession(req as AuthenticatedRequest, res);
|
||||
if (!session) return;
|
||||
try {
|
||||
res.json({ deleted: await emptyTrash(await getSessionSftp(session)) });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
app.put("/ssh/file_manager/ssh/trash-retention", async (req, res) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
if (!(await permissionManager.isAdmin(userId))) {
|
||||
return res.status(403).json({ error: "Admin access required" });
|
||||
}
|
||||
const retentionDays = Number(req.body?.retentionDays);
|
||||
if (
|
||||
!Number.isInteger(retentionDays) ||
|
||||
retentionDays < 1 ||
|
||||
retentionDays > 3650
|
||||
) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "Retention must be between 1 and 3650 days" });
|
||||
}
|
||||
await createCurrentSettingsRepository().upsert(
|
||||
"file_manager_trash_retention_days",
|
||||
String(retentionDays),
|
||||
);
|
||||
return res.json({ retentionDays });
|
||||
});
|
||||
/**
|
||||
* @openapi
|
||||
* /ssh/file_manager/ssh/createFile:
|
||||
* post:
|
||||
* summary: Create a file
|
||||
* description: Creates an empty file on the remote host.
|
||||
* tags:
|
||||
* - File Manager
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* sessionId:
|
||||
* type: string
|
||||
* path:
|
||||
* type: string
|
||||
* fileName:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: File created successfully.
|
||||
* 400:
|
||||
* description: Missing required parameters or SSH connection not established.
|
||||
* 403:
|
||||
* description: Permission denied.
|
||||
* 500:
|
||||
* description: Failed to create file.
|
||||
*/
|
||||
app.post("/ssh/file_manager/ssh/createFile", async (req, res) => {
|
||||
const { sessionId, path: filePath, fileName } = req.body;
|
||||
const sshConn = sshSessions[sessionId];
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
|
||||
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 (!verifySessionOwnership(sshConn, userId)) {
|
||||
return res.status(403).json({ error: "Session access denied" });
|
||||
}
|
||||
|
||||
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`;
|
||||
|
||||
execChannel(sshConn, 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}` });
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /ssh/file_manager/ssh/createFolder:
|
||||
* post:
|
||||
* summary: Create a folder
|
||||
* description: Creates a new folder on the remote host.
|
||||
* tags:
|
||||
* - File Manager
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* sessionId:
|
||||
* type: string
|
||||
* path:
|
||||
* type: string
|
||||
* folderName:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Folder created successfully.
|
||||
* 400:
|
||||
* description: Missing required parameters or SSH connection not established.
|
||||
* 403:
|
||||
* description: Permission denied.
|
||||
* 500:
|
||||
* description: Failed to create folder.
|
||||
*/
|
||||
app.post("/ssh/file_manager/ssh/createFolder", async (req, res) => {
|
||||
const { sessionId, path: folderPath, folderName } = req.body;
|
||||
const sshConn = sshSessions[sessionId];
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
|
||||
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 (!verifySessionOwnership(sshConn, userId)) {
|
||||
return res.status(403).json({ error: "Session access denied" });
|
||||
}
|
||||
|
||||
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;
|
||||
fileLogger.info("Creating directory", {
|
||||
operation: "file_mkdir",
|
||||
sessionId,
|
||||
userId,
|
||||
path: fullPath,
|
||||
});
|
||||
try {
|
||||
await ensureDirectoryTreeSftp(await getSessionSftp(sshConn), fullPath);
|
||||
res.json({
|
||||
message: "Folder created successfully",
|
||||
path: fullPath,
|
||||
toast: { type: "success", message: `Folder created: ${fullPath}` },
|
||||
});
|
||||
} catch (error) {
|
||||
fileLogger.error("SFTP createFolder failed", error);
|
||||
const code = (error as { code?: string | number }).code;
|
||||
res.status(code === 3 || code === "EACCES" ? 403 : 500).json({
|
||||
error: (error as Error).message,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /ssh/file_manager/ssh/deleteItem:
|
||||
* delete:
|
||||
* summary: Delete a file or directory
|
||||
* description: Deletes a file or directory on the remote host.
|
||||
* tags:
|
||||
* - File Manager
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* sessionId:
|
||||
* type: string
|
||||
* path:
|
||||
* type: string
|
||||
* isDirectory:
|
||||
* type: boolean
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Item deleted successfully.
|
||||
* 400:
|
||||
* description: Missing required parameters or SSH connection not established.
|
||||
* 403:
|
||||
* description: Permission denied.
|
||||
* 500:
|
||||
* description: Failed to delete item.
|
||||
*/
|
||||
app.delete("/ssh/file_manager/ssh/deleteItem", async (req, res) => {
|
||||
const { sessionId, path: itemPath, isDirectory, permanent } = req.body;
|
||||
const sshConn = sshSessions[sessionId];
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
|
||||
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 (!verifySessionOwnership(sshConn, userId)) {
|
||||
return res.status(403).json({ error: "Session access denied" });
|
||||
}
|
||||
|
||||
if (!itemPath) {
|
||||
return res.status(400).json({ error: "Item path is required" });
|
||||
}
|
||||
|
||||
fileLogger.info("Deleting item", {
|
||||
operation: "file_delete",
|
||||
sessionId,
|
||||
userId,
|
||||
path: itemPath,
|
||||
type: isDirectory ? "directory" : "file",
|
||||
});
|
||||
sshConn.lastActive = Date.now();
|
||||
|
||||
if (!permanent) {
|
||||
try {
|
||||
const sftp = await getSessionSftp(sshConn);
|
||||
await listTrash(sftp, getTrashRetentionDays());
|
||||
const item = await moveToTrash(sftp, itemPath);
|
||||
fileLogger.success("Item moved to trash", {
|
||||
operation: "file_trash_success",
|
||||
sessionId,
|
||||
userId,
|
||||
path: itemPath,
|
||||
trashId: item.id,
|
||||
});
|
||||
return res.json({
|
||||
message: "Item moved to trash",
|
||||
path: itemPath,
|
||||
trashItem: item,
|
||||
});
|
||||
} catch (error) {
|
||||
fileLogger.error("Failed to move item to trash", error, {
|
||||
operation: "file_trash_failed",
|
||||
sessionId,
|
||||
userId,
|
||||
path: itemPath,
|
||||
});
|
||||
return res.status(409).json({
|
||||
error: (error as Error).message,
|
||||
trashUnavailable: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const { command: deleteCommand, commandWithSuccess } = buildDeleteCommand(
|
||||
itemPath,
|
||||
Boolean(isDirectory),
|
||||
);
|
||||
|
||||
const executeDelete = (useSudo: boolean): Promise<void> => {
|
||||
return new Promise((resolve) => {
|
||||
if (useSudo && sshConn.sudoPassword) {
|
||||
execWithSudo(sshConn, deleteCommand, sshConn.sudoPassword).then(
|
||||
(result) => {
|
||||
if (
|
||||
result.code === 0 ||
|
||||
(!result.stderr.includes("Permission denied") &&
|
||||
!result.stdout.includes("Permission denied"))
|
||||
) {
|
||||
res.json({
|
||||
message: "Item deleted successfully",
|
||||
path: itemPath,
|
||||
toast: {
|
||||
type: "success",
|
||||
message: `${isDirectory ? "Directory" : "File"} deleted: ${itemPath}`,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
res.status(500).json({
|
||||
error: `Delete failed: ${result.stderr || result.stdout}`,
|
||||
});
|
||||
}
|
||||
resolve();
|
||||
},
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
execChannel(sshConn, commandWithSuccess, (err, stream) => {
|
||||
if (err) {
|
||||
fileLogger.error("SSH deleteItem error:", err);
|
||||
res.status(500).json({ error: err.message });
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
let outputData = "";
|
||||
let errorData = "";
|
||||
let permissionDenied = false;
|
||||
|
||||
stream.on("data", (chunk: Buffer) => {
|
||||
outputData += chunk.toString();
|
||||
});
|
||||
|
||||
stream.stderr.on("data", (chunk: Buffer) => {
|
||||
errorData += chunk.toString();
|
||||
if (chunk.toString().includes("Permission denied")) {
|
||||
permissionDenied = true;
|
||||
}
|
||||
});
|
||||
|
||||
stream.on("close", (code) => {
|
||||
if (permissionDenied) {
|
||||
if (sshConn.sudoPassword) {
|
||||
executeDelete(true).then(resolve);
|
||||
return;
|
||||
}
|
||||
fileLogger.error(`Permission denied deleting: ${itemPath}`);
|
||||
res.status(403).json({
|
||||
error: `Permission denied: Cannot delete ${itemPath}.`,
|
||||
needsSudo: true,
|
||||
});
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
if (outputData.includes("SUCCESS")) {
|
||||
fileLogger.success("Item deleted successfully", {
|
||||
operation: "file_delete_success",
|
||||
sessionId,
|
||||
userId,
|
||||
path: itemPath,
|
||||
});
|
||||
res.json({
|
||||
message: "Item deleted successfully",
|
||||
path: itemPath,
|
||||
toast: {
|
||||
type: "success",
|
||||
message: `${isDirectory ? "Directory" : "File"} deleted: ${itemPath}`,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
const detail =
|
||||
errorData.trim() ||
|
||||
outputData.trim() ||
|
||||
`command exited with code ${code} and produced no output (the remote shell may not support the delete command)`;
|
||||
fileLogger.error(`Delete failed for ${itemPath}: ${detail}`, {
|
||||
operation: "file_delete_failed",
|
||||
sessionId,
|
||||
userId,
|
||||
path: itemPath,
|
||||
});
|
||||
res.status(500).json({
|
||||
error: `Delete failed: ${detail}`,
|
||||
});
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
|
||||
stream.on("error", (streamErr) => {
|
||||
fileLogger.error("SSH deleteItem stream error:", streamErr);
|
||||
res
|
||||
.status(500)
|
||||
.json({ error: `Stream error: ${streamErr.message}` });
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
await executeDelete(false);
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /ssh/file_manager/ssh/renameItem:
|
||||
* put:
|
||||
* summary: Rename a file or directory
|
||||
* description: Renames a file or directory on the remote host.
|
||||
* tags:
|
||||
* - File Manager
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* sessionId:
|
||||
* type: string
|
||||
* oldPath:
|
||||
* type: string
|
||||
* newName:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Item renamed successfully.
|
||||
* 400:
|
||||
* description: Missing required parameters or SSH connection not established.
|
||||
* 403:
|
||||
* description: Permission denied.
|
||||
* 500:
|
||||
* description: Failed to rename item.
|
||||
*/
|
||||
app.put("/ssh/file_manager/ssh/renameItem", async (req, res) => {
|
||||
const { sessionId, oldPath, newName } = req.body;
|
||||
const sshConn = sshSessions[sessionId];
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
|
||||
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 (!verifySessionOwnership(sshConn, userId)) {
|
||||
return res.status(403).json({ error: "Session access denied" });
|
||||
}
|
||||
|
||||
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;
|
||||
fileLogger.info("Renaming item", {
|
||||
operation: "file_rename",
|
||||
sessionId,
|
||||
userId,
|
||||
from: oldPath,
|
||||
to: newPath,
|
||||
});
|
||||
const escapedOldPath = oldPath.replace(/'/g, "'\"'\"'");
|
||||
const escapedNewPath = newPath.replace(/'/g, "'\"'\"'");
|
||||
|
||||
const renameCommand = `mv '${escapedOldPath}' '${escapedNewPath}' && echo "SUCCESS" && exit 0`;
|
||||
|
||||
execChannel(sshConn, 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")) {
|
||||
fileLogger.success("Item renamed successfully", {
|
||||
operation: "file_rename_success",
|
||||
sessionId,
|
||||
userId,
|
||||
from: oldPath,
|
||||
to: newPath,
|
||||
});
|
||||
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;
|
||||
}
|
||||
|
||||
fileLogger.success("Item renamed successfully", {
|
||||
operation: "file_rename_success",
|
||||
sessionId,
|
||||
userId,
|
||||
from: oldPath,
|
||||
to: newPath,
|
||||
});
|
||||
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}` });
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /ssh/file_manager/ssh/moveItem:
|
||||
* put:
|
||||
* summary: Move a file or directory
|
||||
* description: Moves a file or directory on the remote host.
|
||||
* tags:
|
||||
* - File Manager
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* sessionId:
|
||||
* type: string
|
||||
* oldPath:
|
||||
* type: string
|
||||
* newPath:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Item moved successfully.
|
||||
* 400:
|
||||
* description: Missing required parameters or SSH connection not established.
|
||||
* 403:
|
||||
* description: Permission denied.
|
||||
* 408:
|
||||
* description: Move operation timed out.
|
||||
* 500:
|
||||
* description: Failed to move item.
|
||||
*/
|
||||
app.put("/ssh/file_manager/ssh/moveItem", async (req, res) => {
|
||||
const { sessionId, oldPath, newPath } = req.body;
|
||||
const sshConn = sshSessions[sessionId];
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
|
||||
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 (!verifySessionOwnership(sshConn, userId)) {
|
||||
return res.status(403).json({ error: "Session access denied" });
|
||||
}
|
||||
|
||||
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`;
|
||||
|
||||
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);
|
||||
|
||||
execChannel(sshConn, 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}` });
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,282 +0,0 @@
|
||||
import type { Client as SSHClient } from "ssh2";
|
||||
|
||||
// Serializes SSH channel open requests so only one channel negotiation is
|
||||
// in-flight at a time per session. Once the channel is established the slot
|
||||
// is released immediately so the next open can proceed; the channels
|
||||
// themselves remain open concurrently.
|
||||
export class ChannelOpenSerializer {
|
||||
private tail: Promise<void> = Promise.resolve();
|
||||
|
||||
run<T>(action: () => Promise<T>): Promise<T> {
|
||||
const next = this.tail.then(
|
||||
() => action(),
|
||||
() => action(),
|
||||
);
|
||||
this.tail = next.then(
|
||||
() => {},
|
||||
() => {},
|
||||
);
|
||||
return next;
|
||||
}
|
||||
}
|
||||
|
||||
export interface SSHSession {
|
||||
client: SSHClient;
|
||||
isConnected: boolean;
|
||||
lastActive: number;
|
||||
timeout?: NodeJS.Timeout;
|
||||
activeOperations: number;
|
||||
sudoPassword?: string;
|
||||
sftp?: import("ssh2").SFTPWrapper;
|
||||
sftpPending?: Promise<import("ssh2").SFTPWrapper>;
|
||||
channelOpener: ChannelOpenSerializer;
|
||||
poolKey?: string;
|
||||
userId?: string;
|
||||
hostId?: number;
|
||||
ip?: string;
|
||||
port?: number;
|
||||
username?: string;
|
||||
transferDedicated?: boolean;
|
||||
transferId?: string;
|
||||
browseSessionId?: string;
|
||||
scpLegacy?: boolean;
|
||||
}
|
||||
|
||||
export interface PendingTOTPSession {
|
||||
client: SSHClient;
|
||||
finish: (responses: string[]) => void;
|
||||
config: import("ssh2").ConnectConfig;
|
||||
createdAt: number;
|
||||
sessionId: string;
|
||||
hostId?: number;
|
||||
ip?: string;
|
||||
port?: number;
|
||||
username?: string;
|
||||
userId?: string;
|
||||
prompts?: Array<{ prompt: string; echo: boolean }>;
|
||||
totpPromptIndex?: number;
|
||||
resolvedPassword?: string;
|
||||
totpAttempts: number;
|
||||
isWarpgate?: boolean;
|
||||
}
|
||||
|
||||
export function execWithSudo(
|
||||
session: SSHSession,
|
||||
command: string,
|
||||
sudoPassword: string,
|
||||
): Promise<{ stdout: string; stderr: string; code: number }> {
|
||||
return execWithSudoBuffer(session, command, sudoPassword).then((result) => ({
|
||||
stdout: result.stdout.toString("utf8"),
|
||||
stderr: result.stderr,
|
||||
code: result.code,
|
||||
}));
|
||||
}
|
||||
|
||||
export function execWithSudoBuffer(
|
||||
session: SSHSession,
|
||||
command: string,
|
||||
sudoPassword: string,
|
||||
maxStdoutBytes?: number,
|
||||
): Promise<{
|
||||
stdout: Buffer;
|
||||
stderr: string;
|
||||
code: number;
|
||||
exceededLimit?: boolean;
|
||||
}> {
|
||||
return new Promise((resolve) => {
|
||||
const sudoCommand = `sudo -S -p '' ${command} 2>&1`;
|
||||
|
||||
execChannel(session, sudoCommand, (err, stream) => {
|
||||
if (err) {
|
||||
resolve({ stdout: Buffer.alloc(0), stderr: err.message, code: 1 });
|
||||
return;
|
||||
}
|
||||
|
||||
const stdoutChunks: Buffer[] = [];
|
||||
let stdoutBytes = 0;
|
||||
let stderr = "";
|
||||
let settled = false;
|
||||
|
||||
const finish = (
|
||||
code: number,
|
||||
extra: { exceededLimit?: boolean } = {},
|
||||
) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
resolve({
|
||||
stdout: Buffer.concat(stdoutChunks),
|
||||
stderr,
|
||||
code,
|
||||
...extra,
|
||||
});
|
||||
};
|
||||
|
||||
stream.on("data", (chunk: Buffer) => {
|
||||
if (settled) return;
|
||||
stdoutBytes += chunk.length;
|
||||
if (maxStdoutBytes !== undefined && stdoutBytes > maxStdoutBytes) {
|
||||
stderr = `Command output exceeds ${maxStdoutBytes} bytes`;
|
||||
finish(1, { exceededLimit: true });
|
||||
stream.close();
|
||||
return;
|
||||
}
|
||||
stdoutChunks.push(chunk);
|
||||
});
|
||||
|
||||
stream.stderr.on("data", (chunk: Buffer) => {
|
||||
stderr += chunk.toString();
|
||||
});
|
||||
|
||||
stream.on("close", (code: number) => {
|
||||
if (settled) return;
|
||||
let stdout = Buffer.concat(stdoutChunks);
|
||||
const sudoPromptMatch = stdout
|
||||
.toString("utf8", 0, Math.min(stdout.length, 256))
|
||||
.match(/^\[sudo\] password for .+?:\s*/);
|
||||
if (sudoPromptMatch) {
|
||||
stdout = stdout.subarray(Buffer.byteLength(sudoPromptMatch[0]));
|
||||
}
|
||||
settled = true;
|
||||
resolve({ stdout, stderr, code: code || 0 });
|
||||
});
|
||||
|
||||
stream.on("error", (streamErr: Error) => {
|
||||
stderr = streamErr.message;
|
||||
finish(1);
|
||||
});
|
||||
|
||||
stream.write(`${sudoPassword}\n`);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function execBuffer(
|
||||
session: SSHSession,
|
||||
command: string,
|
||||
maxStdoutBytes?: number,
|
||||
): Promise<{
|
||||
stdout: Buffer;
|
||||
stderr: string;
|
||||
code: number;
|
||||
exceededLimit?: boolean;
|
||||
}> {
|
||||
return new Promise((resolve) => {
|
||||
execChannel(session, command, (err, stream) => {
|
||||
if (err) {
|
||||
resolve({ stdout: Buffer.alloc(0), stderr: err.message, code: 1 });
|
||||
return;
|
||||
}
|
||||
|
||||
const stdoutChunks: Buffer[] = [];
|
||||
let stdoutBytes = 0;
|
||||
let stderr = "";
|
||||
let settled = false;
|
||||
|
||||
const finish = (
|
||||
code: number,
|
||||
extra: { exceededLimit?: boolean } = {},
|
||||
) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
resolve({
|
||||
stdout: Buffer.concat(stdoutChunks),
|
||||
stderr,
|
||||
code,
|
||||
...extra,
|
||||
});
|
||||
};
|
||||
|
||||
stream.on("data", (chunk: Buffer) => {
|
||||
if (settled) return;
|
||||
stdoutBytes += chunk.length;
|
||||
if (maxStdoutBytes !== undefined && stdoutBytes > maxStdoutBytes) {
|
||||
stderr = `Command output exceeds ${maxStdoutBytes} bytes`;
|
||||
finish(1, { exceededLimit: true });
|
||||
stream.close();
|
||||
return;
|
||||
}
|
||||
stdoutChunks.push(chunk);
|
||||
});
|
||||
stream.stderr.on("data", (chunk: Buffer) => {
|
||||
stderr += chunk.toString();
|
||||
});
|
||||
stream.on("close", (code: number) => finish(code || 0));
|
||||
stream.on("error", (streamErr: Error) => {
|
||||
stderr = stderr || streamErr.message;
|
||||
finish(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function getSessionSftp(
|
||||
session: SSHSession,
|
||||
): Promise<import("ssh2").SFTPWrapper> {
|
||||
if (session.sftp) {
|
||||
return Promise.resolve(session.sftp);
|
||||
}
|
||||
|
||||
if (session.sftpPending) {
|
||||
return session.sftpPending;
|
||||
}
|
||||
|
||||
const openOnce = (): Promise<import("ssh2").SFTPWrapper> =>
|
||||
session.channelOpener.run(
|
||||
() =>
|
||||
new Promise<import("ssh2").SFTPWrapper>((resolve, reject) => {
|
||||
session.client.sftp((err, sftp) => {
|
||||
if (err) return reject(err);
|
||||
session.sftp = sftp;
|
||||
sftp.on("error", () => {
|
||||
session.sftp = undefined;
|
||||
});
|
||||
sftp.on("close", () => {
|
||||
session.sftp = undefined;
|
||||
});
|
||||
resolve(sftp);
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
session.sftpPending = openOnce()
|
||||
.catch((err: Error) => {
|
||||
const isChannelFailure =
|
||||
err.message.toLowerCase().includes("channel open failure") ||
|
||||
err.message.toLowerCase().includes("open failed");
|
||||
if (isChannelFailure) {
|
||||
return new Promise<import("ssh2").SFTPWrapper>((resolve, reject) =>
|
||||
setTimeout(() => openOnce().then(resolve, reject), 500),
|
||||
);
|
||||
}
|
||||
return Promise.reject(err);
|
||||
})
|
||||
.finally(() => {
|
||||
session.sftpPending = undefined;
|
||||
});
|
||||
|
||||
return session.sftpPending;
|
||||
}
|
||||
|
||||
export function execChannel(
|
||||
session: SSHSession,
|
||||
command: string,
|
||||
callback: (
|
||||
err: Error | undefined,
|
||||
stream: import("ssh2").ClientChannel,
|
||||
) => void,
|
||||
): void {
|
||||
session.channelOpener
|
||||
.run(
|
||||
() =>
|
||||
new Promise<import("ssh2").ClientChannel>((resolve, reject) => {
|
||||
session.client.exec(command, (err, stream) => {
|
||||
if (err) return reject(err);
|
||||
resolve(stream);
|
||||
});
|
||||
}),
|
||||
)
|
||||
.then(
|
||||
(stream) => callback(undefined, stream),
|
||||
(err: Error) => callback(err, undefined as never),
|
||||
);
|
||||
}
|
||||
@@ -1,120 +0,0 @@
|
||||
type SFTPWrapper = import("ssh2").SFTPWrapper;
|
||||
|
||||
export const SFTP_OPEN_READ = 0x00000001;
|
||||
export const SFTP_OPEN_WRITE = 0x00000002 | 0x00000008 | 0x00000010;
|
||||
export const SFTP_OPEN_WRITE_RESUME = 0x00000001 | 0x00000002 | 0x00000008;
|
||||
|
||||
export function promisifySftpStat(
|
||||
sftp: SFTPWrapper,
|
||||
path: string,
|
||||
): Promise<import("ssh2").Stats> {
|
||||
return new Promise((resolve, reject) => {
|
||||
sftp.stat(path, (err, stats) => {
|
||||
if (err) reject(err);
|
||||
else resolve(stats);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function promisifySftpUnlink(
|
||||
sftp: SFTPWrapper,
|
||||
path: string,
|
||||
): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
sftp.unlink(path, (err) => {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function promisifySftpRmdir(
|
||||
sftp: SFTPWrapper,
|
||||
path: string,
|
||||
): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
sftp.rmdir(path, (err) => {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function promisifySftpMkdir(
|
||||
sftp: SFTPWrapper,
|
||||
path: string,
|
||||
mode: number,
|
||||
): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
sftp.mkdir(path, { mode }, (err) => {
|
||||
if (err && (err as NodeJS.ErrnoException).code !== "EEXIST") {
|
||||
reject(err);
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function promisifySftpChmod(
|
||||
sftp: SFTPWrapper,
|
||||
path: string,
|
||||
mode: number,
|
||||
): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
sftp.chmod(path, mode, (err) => {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function promisifySftpReaddir(
|
||||
sftp: SFTPWrapper,
|
||||
path: string,
|
||||
): Promise<Array<{ filename: string; attrs: import("ssh2").Stats }>> {
|
||||
return new Promise((resolve, reject) => {
|
||||
sftp.readdir(path, (err, list) => {
|
||||
if (err) reject(err);
|
||||
else resolve(list);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function promisifySftpOpen(
|
||||
sftp: SFTPWrapper,
|
||||
path: string,
|
||||
flags: number,
|
||||
mode: number,
|
||||
): Promise<Buffer> {
|
||||
return new Promise((resolve, reject) => {
|
||||
sftp.open(path, flags, mode, (err, handle) => {
|
||||
if (err) reject(err);
|
||||
else resolve(handle);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function promisifySftpClose(
|
||||
sftp: SFTPWrapper,
|
||||
handle: Buffer,
|
||||
): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
sftp.close(handle, (err) => {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function promisifySftpFstat(
|
||||
sftp: SFTPWrapper,
|
||||
handle: Buffer,
|
||||
): Promise<import("ssh2").Stats> {
|
||||
return new Promise((resolve, reject) => {
|
||||
sftp.fstat(handle, (err, stats) => {
|
||||
if (err) reject(err);
|
||||
else resolve(stats);
|
||||
});
|
||||
});
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,52 +0,0 @@
|
||||
export class TransferCancelledError extends Error {
|
||||
constructor() {
|
||||
super("Transfer cancelled");
|
||||
this.name = "TransferCancelledError";
|
||||
}
|
||||
}
|
||||
|
||||
export class TransferStalledError extends Error {
|
||||
readonly byteOffset?: number;
|
||||
readonly segmentIndex?: number;
|
||||
|
||||
constructor(byteOffset?: number, segmentIndex?: number) {
|
||||
const pos = byteOffset !== undefined ? ` at byte offset ${byteOffset}` : "";
|
||||
const seg = segmentIndex !== undefined ? ` (segment ${segmentIndex})` : "";
|
||||
super(`Transfer stalled — no data moved for 45 seconds${pos}${seg}`);
|
||||
this.name = "TransferStalledError";
|
||||
this.byteOffset = byteOffset;
|
||||
this.segmentIndex = segmentIndex;
|
||||
}
|
||||
}
|
||||
|
||||
export class TransferConnectionLostError extends Error {
|
||||
constructor(message = "Transfer SSH connection lost") {
|
||||
super(message);
|
||||
this.name = "TransferConnectionLostError";
|
||||
}
|
||||
}
|
||||
|
||||
export function isRecoverableTransferConnectionError(err: unknown): boolean {
|
||||
if (!(err instanceof Error)) return false;
|
||||
const msg = err.message.toLowerCase();
|
||||
return (
|
||||
msg.includes("no response from server") ||
|
||||
msg.includes("connection lost") ||
|
||||
msg.includes("not connected") ||
|
||||
msg.includes("econnreset") ||
|
||||
msg.includes("econnrefused") ||
|
||||
msg.includes("etimedout") ||
|
||||
msg.includes("socket hang up") ||
|
||||
msg.includes("protocol error") ||
|
||||
msg.includes("connection closed") ||
|
||||
msg.includes("channel open failure")
|
||||
);
|
||||
}
|
||||
|
||||
export function isRecoverableTransferError(err: unknown): boolean {
|
||||
return (
|
||||
err instanceof TransferStalledError ||
|
||||
err instanceof TransferConnectionLostError ||
|
||||
isRecoverableTransferConnectionError(err)
|
||||
);
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
import { networkInterfaces } from "os";
|
||||
import { normalizeSftpPath } from "../transfer-paths.js";
|
||||
|
||||
let cachedLocalAddresses: Set<string> | null = null;
|
||||
|
||||
export function normalizeHostAddress(host: string): string {
|
||||
const trimmed = host.trim().toLowerCase();
|
||||
if (trimmed.startsWith("[") && trimmed.endsWith("]")) {
|
||||
return trimmed.slice(1, -1);
|
||||
}
|
||||
return trimmed.split(":")[0] ?? trimmed;
|
||||
}
|
||||
|
||||
function getLocalAddresses(): Set<string> {
|
||||
if (cachedLocalAddresses) return cachedLocalAddresses;
|
||||
|
||||
const addresses = new Set(["127.0.0.1", "::1", "localhost"]);
|
||||
for (const ifaces of Object.values(networkInterfaces())) {
|
||||
if (!ifaces) continue;
|
||||
for (const iface of ifaces) {
|
||||
if (!iface.internal && iface.family === "IPv4") {
|
||||
addresses.add(iface.address.toLowerCase());
|
||||
}
|
||||
}
|
||||
}
|
||||
cachedLocalAddresses = addresses;
|
||||
return addresses;
|
||||
}
|
||||
|
||||
export function isLocalSshEndpoint(ip?: string): boolean {
|
||||
if (!ip) return false;
|
||||
const bare = normalizeHostAddress(ip);
|
||||
if (bare === "localhost" || bare === "127.0.0.1" || bare === "::1") {
|
||||
return true;
|
||||
}
|
||||
return getLocalAddresses().has(bare);
|
||||
}
|
||||
|
||||
export function escapeShell(s: string): string {
|
||||
return s.replace(/'/g, "'\"'\"'");
|
||||
}
|
||||
|
||||
export function isRootOnlyPath(path: string): boolean {
|
||||
const normalized = normalizeSftpPath(path);
|
||||
return (
|
||||
normalized === "/" ||
|
||||
/^\/[A-Za-z]:$/.test(normalized) ||
|
||||
/^[A-Za-z]:$/.test(normalized)
|
||||
);
|
||||
}
|
||||
|
||||
export function isPermissionError(err: Error): boolean {
|
||||
const msg = err.message.toLowerCase();
|
||||
return (
|
||||
msg.includes("permission denied") ||
|
||||
msg.includes("eacces") ||
|
||||
msg.includes("access denied")
|
||||
);
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
type SFTPWrapper = import("ssh2").SFTPWrapper;
|
||||
|
||||
export interface TransferIntegrityResult {
|
||||
algorithm: "sha256";
|
||||
digest: string;
|
||||
}
|
||||
|
||||
export async function hashSftpFile(
|
||||
sftp: SFTPWrapper,
|
||||
path: string,
|
||||
shouldAbort: () => boolean = () => false,
|
||||
createAbortError: () => Error = () => new Error("Transfer cancelled"),
|
||||
): Promise<string> {
|
||||
const stream = sftp.createReadStream(path);
|
||||
const hash = createHash("sha256");
|
||||
|
||||
try {
|
||||
for await (const chunk of stream) {
|
||||
if (shouldAbort()) {
|
||||
const error = createAbortError();
|
||||
stream.destroy(error);
|
||||
throw error;
|
||||
}
|
||||
hash.update(chunk);
|
||||
}
|
||||
} finally {
|
||||
stream.destroy();
|
||||
}
|
||||
|
||||
if (shouldAbort()) throw createAbortError();
|
||||
return hash.digest("hex");
|
||||
}
|
||||
|
||||
export async function verifySftpFileIntegrity(
|
||||
sourceSftp: SFTPWrapper,
|
||||
destSftp: SFTPWrapper,
|
||||
sourcePath: string,
|
||||
destPath: string,
|
||||
shouldAbort: () => boolean = () => false,
|
||||
createAbortError: () => Error = () => new Error("Transfer cancelled"),
|
||||
): Promise<TransferIntegrityResult> {
|
||||
const [sourceDigest, destDigest] = await Promise.all([
|
||||
hashSftpFile(sourceSftp, sourcePath, shouldAbort, createAbortError),
|
||||
hashSftpFile(destSftp, destPath, shouldAbort, createAbortError),
|
||||
]);
|
||||
|
||||
if (sourceDigest !== destDigest) {
|
||||
throw new Error(`SHA-256 verification failed for ${sourcePath}`);
|
||||
}
|
||||
|
||||
return { algorithm: "sha256", digest: sourceDigest };
|
||||
}
|
||||
@@ -1,205 +0,0 @@
|
||||
import type { TransferPlatform } from "../transfer-paths.js";
|
||||
import { deflateRawSync } from "node:zlib";
|
||||
|
||||
export type TransferMethodPreference = "auto" | "tar" | "item_sftp";
|
||||
|
||||
export interface TransferScanSummary {
|
||||
fileCount: number;
|
||||
totalBytes: number;
|
||||
largestFileBytes: number;
|
||||
/** Share of total bytes in likely incompressible file types (0–1). */
|
||||
incompressibleRatio: number;
|
||||
/** Share inferred from bounded content samples, when available. */
|
||||
sampledIncompressibleRatio?: number;
|
||||
}
|
||||
|
||||
const INCOMPRESSIBLE_EXT =
|
||||
/\.(zip|gz|bz2|xz|7z|rar|tar|tgz|jpg|jpeg|png|gif|webp|mp3|mp4|mkv|avi|mov|wmv|iso|dmg|deb|rpm|pdf|db|sqlite|wasm|vmdk|qcow2)$/i;
|
||||
|
||||
const GB = 1024 * 1024 * 1024;
|
||||
const MB = 1024 * 1024;
|
||||
|
||||
export function isLikelyIncompressiblePath(path: string): boolean {
|
||||
return INCOMPRESSIBLE_EXT.test(path);
|
||||
}
|
||||
|
||||
export function buildTransferScanSummary(
|
||||
items: Array<{ sourcePath: string; size: number }>,
|
||||
): TransferScanSummary {
|
||||
let totalBytes = 0;
|
||||
let largestFileBytes = 0;
|
||||
let incompressibleBytes = 0;
|
||||
|
||||
for (const item of items) {
|
||||
totalBytes += item.size;
|
||||
largestFileBytes = Math.max(largestFileBytes, item.size);
|
||||
if (isLikelyIncompressiblePath(item.sourcePath)) {
|
||||
incompressibleBytes += item.size;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
fileCount: items.length,
|
||||
totalBytes,
|
||||
largestFileBytes,
|
||||
incompressibleRatio: totalBytes > 0 ? incompressibleBytes / totalBytes : 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function estimateIncompressibleSample(data: Buffer): boolean {
|
||||
if (data.length === 0) return false;
|
||||
return deflateRawSync(data, { level: 1 }).length / data.length >= 0.9;
|
||||
}
|
||||
|
||||
function effectiveIncompressibleRatio(summary: TransferScanSummary): number {
|
||||
return summary.sampledIncompressibleRatio ?? summary.incompressibleRatio;
|
||||
}
|
||||
|
||||
/**
|
||||
* Choose tar vs per-item SFTP for directory / multi-file transfers.
|
||||
* Single-file stream transfers bypass this entirely.
|
||||
*/
|
||||
export function resolveArchiveTransferMethod(
|
||||
preference: TransferMethodPreference,
|
||||
summary: TransferScanSummary,
|
||||
sourcePlatform: TransferPlatform,
|
||||
destPlatform: TransferPlatform,
|
||||
sourceHasTar: boolean,
|
||||
destHasTar: boolean,
|
||||
): "tar" | "item_sftp" {
|
||||
if (sourcePlatform === "windows" || destPlatform === "windows") {
|
||||
return "item_sftp";
|
||||
}
|
||||
|
||||
if (preference === "item_sftp") {
|
||||
return "item_sftp";
|
||||
}
|
||||
|
||||
if (preference === "tar") {
|
||||
return sourceHasTar && destHasTar ? "tar" : "item_sftp";
|
||||
}
|
||||
|
||||
// auto
|
||||
if (!sourceHasTar || !destHasTar) {
|
||||
return "item_sftp";
|
||||
}
|
||||
|
||||
const { fileCount, totalBytes, largestFileBytes } = summary;
|
||||
const incompressibleRatio = effectiveIncompressibleRatio(summary);
|
||||
|
||||
if (fileCount === 0) {
|
||||
return "item_sftp";
|
||||
}
|
||||
|
||||
if (fileCount === 1 && largestFileBytes >= 2 * GB) {
|
||||
return "item_sftp";
|
||||
}
|
||||
|
||||
// Multi-item sets with a large file: tar only when data is likely compressible
|
||||
// (mostly video/zip → per-file SFTP is simpler and avoids pack/unpack time).
|
||||
if (
|
||||
fileCount > 1 &&
|
||||
largestFileBytes >= 2 * GB &&
|
||||
totalBytes >= 500 * MB &&
|
||||
incompressibleRatio < 0.7
|
||||
) {
|
||||
return "tar";
|
||||
}
|
||||
|
||||
if (fileCount === 1 && largestFileBytes >= 5 * GB) {
|
||||
return "item_sftp";
|
||||
}
|
||||
|
||||
if (totalBytes >= 10 * GB && incompressibleRatio >= 0.5) {
|
||||
return "item_sftp";
|
||||
}
|
||||
|
||||
if (incompressibleRatio >= 0.85) {
|
||||
return "item_sftp";
|
||||
}
|
||||
|
||||
if (fileCount >= 100) {
|
||||
return "tar";
|
||||
}
|
||||
|
||||
if (fileCount >= 20 && totalBytes >= 50 * MB && incompressibleRatio < 0.5) {
|
||||
return "tar";
|
||||
}
|
||||
|
||||
if (fileCount > 5 && incompressibleRatio < 0.7) {
|
||||
return "tar";
|
||||
}
|
||||
|
||||
return "item_sftp";
|
||||
}
|
||||
|
||||
export type ArchiveTransferReasonKey =
|
||||
| "user_item_sftp"
|
||||
| "user_tar"
|
||||
| "tar_unavailable"
|
||||
| "windows_host"
|
||||
| "auto_multi_large"
|
||||
| "auto_single_large_in_archive"
|
||||
| "auto_many_incompressible"
|
||||
| "auto_many_files"
|
||||
| "auto_default";
|
||||
|
||||
/** i18n key suffix under transfer.methodReason.* */
|
||||
export function getArchiveTransferReasonKey(
|
||||
preference: TransferMethodPreference,
|
||||
resolvedMethod: "tar" | "item_sftp",
|
||||
summary: TransferScanSummary,
|
||||
sourcePlatform: TransferPlatform,
|
||||
destPlatform: TransferPlatform,
|
||||
sourceHasTar: boolean,
|
||||
destHasTar: boolean,
|
||||
): ArchiveTransferReasonKey {
|
||||
if (preference === "item_sftp") return "user_item_sftp";
|
||||
if (preference === "tar") {
|
||||
return sourceHasTar && destHasTar ? "user_tar" : "tar_unavailable";
|
||||
}
|
||||
|
||||
if (sourcePlatform === "windows" || destPlatform === "windows") {
|
||||
return "windows_host";
|
||||
}
|
||||
if (!sourceHasTar || !destHasTar) {
|
||||
return "tar_unavailable";
|
||||
}
|
||||
|
||||
const { fileCount, totalBytes, largestFileBytes } = summary;
|
||||
const incompressibleRatio = effectiveIncompressibleRatio(summary);
|
||||
|
||||
if (
|
||||
fileCount > 1 &&
|
||||
largestFileBytes >= 2 * GB &&
|
||||
totalBytes >= 500 * MB &&
|
||||
incompressibleRatio < 0.7
|
||||
) {
|
||||
return "auto_multi_large";
|
||||
}
|
||||
|
||||
if (fileCount === 1 && largestFileBytes >= 2 * GB) {
|
||||
return "auto_single_large_in_archive";
|
||||
}
|
||||
|
||||
if (totalBytes >= 10 * GB && incompressibleRatio >= 0.5) {
|
||||
return "auto_many_incompressible";
|
||||
}
|
||||
if (incompressibleRatio >= 0.85) {
|
||||
return "auto_many_incompressible";
|
||||
}
|
||||
|
||||
if (
|
||||
fileCount >= 100 ||
|
||||
(fileCount >= 20 && totalBytes >= 50 * MB && incompressibleRatio < 0.5) ||
|
||||
(fileCount > 5 && incompressibleRatio < 0.7)
|
||||
) {
|
||||
return "auto_many_files";
|
||||
}
|
||||
|
||||
if (resolvedMethod === "tar") {
|
||||
return "auto_many_files";
|
||||
}
|
||||
|
||||
return "auto_default";
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
import { basename, joinPath } from "../transfer-paths.js";
|
||||
import {
|
||||
SFTP_OPEN_READ,
|
||||
promisifySftpClose,
|
||||
promisifySftpOpen,
|
||||
promisifySftpReaddir,
|
||||
promisifySftpStat,
|
||||
} from "./sftp-promisify.js";
|
||||
|
||||
type SFTPWrapper = import("ssh2").SFTPWrapper;
|
||||
|
||||
export interface FileWorkItem {
|
||||
sourcePath: string;
|
||||
destPath: string;
|
||||
mode: number;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export async function collectFileWorkItems(
|
||||
sftp: SFTPWrapper,
|
||||
sourcePath: string,
|
||||
destRoot: string,
|
||||
destBaseName?: string,
|
||||
): Promise<FileWorkItem[]> {
|
||||
const stats = await promisifySftpStat(sftp, sourcePath);
|
||||
const name = destBaseName ?? basename(sourcePath);
|
||||
const destPath = joinPath(destRoot, name);
|
||||
|
||||
if (stats.isFile()) {
|
||||
return [
|
||||
{
|
||||
sourcePath,
|
||||
destPath,
|
||||
mode: stats.mode & 0o7777,
|
||||
size: stats.size,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
if (!stats.isDirectory()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const items: FileWorkItem[] = [];
|
||||
const walk = async (srcDir: string, dstDir: string) => {
|
||||
const entries = await promisifySftpReaddir(sftp, srcDir);
|
||||
for (const entry of entries) {
|
||||
if (entry.filename === "." || entry.filename === "..") continue;
|
||||
const srcChild = joinPath(srcDir, entry.filename);
|
||||
const dstChild = joinPath(dstDir, entry.filename);
|
||||
|
||||
if (entry.attrs.isDirectory()) {
|
||||
await walk(srcChild, dstChild);
|
||||
} else if (entry.attrs.isFile()) {
|
||||
items.push({
|
||||
sourcePath: srcChild,
|
||||
destPath: dstChild,
|
||||
mode: entry.attrs.mode & 0o7777,
|
||||
size: entry.attrs.size,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
await walk(sourcePath, destPath);
|
||||
return items;
|
||||
}
|
||||
|
||||
export async function readSftpSample(
|
||||
sftp: SFTPWrapper,
|
||||
path: string,
|
||||
fileSize: number,
|
||||
): Promise<Buffer> {
|
||||
const sampleSize = Math.min(64 * 1024, fileSize);
|
||||
const position = Math.max(0, Math.floor((fileSize - sampleSize) / 2));
|
||||
const handle = await promisifySftpOpen(sftp, path, SFTP_OPEN_READ, 0o666);
|
||||
try {
|
||||
const buffer = Buffer.alloc(sampleSize);
|
||||
const bytesRead = await new Promise<number>((resolve, reject) => {
|
||||
sftp.read(handle, buffer, 0, sampleSize, position, (err, count) => {
|
||||
if (err) reject(err);
|
||||
else resolve(count);
|
||||
});
|
||||
});
|
||||
return buffer.subarray(0, bytesRead);
|
||||
} finally {
|
||||
await promisifySftpClose(sftp, handle).catch(() => {});
|
||||
}
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
export const SFTP_XFER_SEGMENT_SIZE = 256 * 1024 * 1024;
|
||||
export const DEFAULT_PARALLEL_SEGMENT_COUNT = 2;
|
||||
export const MAX_PARALLEL_SEGMENT_COUNT = 8;
|
||||
|
||||
export interface SegmentCopyJob {
|
||||
offset: number;
|
||||
length: number;
|
||||
segmentIndex: number;
|
||||
}
|
||||
|
||||
export function clampParallelSegmentCount(value?: number): number {
|
||||
const n = value ?? DEFAULT_PARALLEL_SEGMENT_COUNT;
|
||||
return Math.max(1, Math.min(MAX_PARALLEL_SEGMENT_COUNT, Math.floor(n)));
|
||||
}
|
||||
|
||||
export function buildSegmentCopyJobs(
|
||||
fileSize: number,
|
||||
initialOffset: number,
|
||||
destResumeSize: number,
|
||||
): SegmentCopyJob[] {
|
||||
const jobs: SegmentCopyJob[] = [];
|
||||
for (
|
||||
let offset = initialOffset;
|
||||
offset < fileSize;
|
||||
offset += SFTP_XFER_SEGMENT_SIZE
|
||||
) {
|
||||
const length = Math.min(SFTP_XFER_SEGMENT_SIZE, fileSize - offset);
|
||||
const segmentIndex = Math.floor(offset / SFTP_XFER_SEGMENT_SIZE);
|
||||
if (destResumeSize >= offset + length) {
|
||||
continue;
|
||||
}
|
||||
const start = destResumeSize > offset ? destResumeSize : offset;
|
||||
jobs.push({
|
||||
offset: start,
|
||||
length: offset + length - start,
|
||||
segmentIndex,
|
||||
});
|
||||
}
|
||||
return jobs;
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
import {
|
||||
buildPathFromSegments,
|
||||
joinPath,
|
||||
normalizeSftpPath,
|
||||
splitPathSegments,
|
||||
} from "../transfer-paths.js";
|
||||
import { isRootOnlyPath } from "./transfer-host-utils.js";
|
||||
import {
|
||||
promisifySftpMkdir,
|
||||
promisifySftpReaddir,
|
||||
promisifySftpRmdir,
|
||||
promisifySftpStat,
|
||||
promisifySftpUnlink,
|
||||
} from "./sftp-promisify.js";
|
||||
|
||||
type SFTPWrapper = import("ssh2").SFTPWrapper;
|
||||
|
||||
export async function ensureDirectoryTreeSftp(
|
||||
sftp: SFTPWrapper,
|
||||
dirPath: string,
|
||||
created: Set<string> = new Set(),
|
||||
): Promise<void> {
|
||||
const normalized = normalizeSftpPath(dirPath);
|
||||
if (!normalized || isRootOnlyPath(normalized)) return;
|
||||
|
||||
const { root, segments } = splitPathSegments(normalized);
|
||||
if (segments.length === 0) return;
|
||||
|
||||
for (let i = 0; i < segments.length; i++) {
|
||||
const current = buildPathFromSegments(root, segments, i + 1);
|
||||
if (created.has(current)) continue;
|
||||
|
||||
try {
|
||||
await promisifySftpMkdir(sftp, current, 0o755);
|
||||
} catch (err) {
|
||||
const code = (err as NodeJS.ErrnoException).code;
|
||||
if (code !== "EEXIST") {
|
||||
try {
|
||||
const stats = await promisifySftpStat(sftp, current);
|
||||
if (!stats.isDirectory()) throw err;
|
||||
} catch {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
created.add(current);
|
||||
}
|
||||
}
|
||||
|
||||
export async function deletePathSftp(
|
||||
sftp: SFTPWrapper,
|
||||
path: string,
|
||||
): Promise<void> {
|
||||
let stats: import("ssh2").Stats;
|
||||
try {
|
||||
stats = await promisifySftpStat(sftp, path);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
if (stats.isDirectory()) {
|
||||
const entries = await promisifySftpReaddir(sftp, path);
|
||||
for (const entry of entries) {
|
||||
if (entry.filename === "." || entry.filename === "..") continue;
|
||||
await deletePathSftp(sftp, joinPath(path, entry.filename));
|
||||
}
|
||||
await promisifySftpRmdir(sftp, path);
|
||||
return;
|
||||
}
|
||||
|
||||
if (stats.isFile()) {
|
||||
await promisifySftpUnlink(sftp, path);
|
||||
}
|
||||
}
|
||||
@@ -1,157 +0,0 @@
|
||||
import { performance } from "node:perf_hooks";
|
||||
|
||||
const TRANSFER_PROGRESS_INTERVAL_MS = 200;
|
||||
|
||||
export type TransferHopId =
|
||||
"source_read" | "dest_sftp_write" | "dest_local_write";
|
||||
|
||||
export interface TransferHopMetrics {
|
||||
id: TransferHopId;
|
||||
bytes: number;
|
||||
/** Wall-clock span from first I/O on this hop to last I/O complete. */
|
||||
spanMs: number;
|
||||
mbPerSec: number;
|
||||
}
|
||||
|
||||
export interface TransferTimings {
|
||||
prepareDestMs?: number;
|
||||
compressMs?: number;
|
||||
transferMs?: number;
|
||||
extractMs?: number;
|
||||
verifyMs?: number;
|
||||
directBenchmarkMs?: number;
|
||||
relayBenchmarkMs?: number;
|
||||
sourceDeleteMs?: number;
|
||||
totalMs?: number;
|
||||
transferBytes?: number;
|
||||
endToEndMbPerSec?: number;
|
||||
hops?: TransferHopMetrics[];
|
||||
}
|
||||
|
||||
export function elapsedMs(start: number): number {
|
||||
return Date.now() - start;
|
||||
}
|
||||
|
||||
export function computeTransferMbPerSec(
|
||||
bytes: number,
|
||||
ms: number,
|
||||
): number | undefined {
|
||||
if (ms <= 0 || bytes <= 0) return undefined;
|
||||
return ((bytes / ms) * 1000) / (1024 * 1024);
|
||||
}
|
||||
|
||||
export interface HopWallClock {
|
||||
firstAt: number | null;
|
||||
lastAt: number | null;
|
||||
}
|
||||
|
||||
export function createHopWallClock(): HopWallClock {
|
||||
return { firstAt: null, lastAt: null };
|
||||
}
|
||||
|
||||
export function noteHopStart(
|
||||
clock: HopWallClock,
|
||||
t: number = performance.now(),
|
||||
): void {
|
||||
if (clock.firstAt === null) clock.firstAt = t;
|
||||
}
|
||||
|
||||
export function noteHopEnd(
|
||||
clock: HopWallClock,
|
||||
t: number = performance.now(),
|
||||
): void {
|
||||
clock.lastAt = t;
|
||||
}
|
||||
|
||||
export function hopSpanMs(clock: HopWallClock): number {
|
||||
if (clock.firstAt === null || clock.lastAt === null) return 0;
|
||||
return Math.max(0, clock.lastAt - clock.firstAt);
|
||||
}
|
||||
|
||||
export function createThrottledProgress(onProgress?: (bytes: number) => void) {
|
||||
let pending = 0;
|
||||
let lastFlush = 0;
|
||||
|
||||
const flush = () => {
|
||||
if (pending > 0) {
|
||||
onProgress?.(pending);
|
||||
pending = 0;
|
||||
lastFlush = Date.now();
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
add(bytes: number) {
|
||||
pending += bytes;
|
||||
const now = Date.now();
|
||||
if (now - lastFlush >= TRANSFER_PROGRESS_INTERVAL_MS) {
|
||||
flush();
|
||||
}
|
||||
},
|
||||
flush,
|
||||
};
|
||||
}
|
||||
|
||||
export interface PipelinedXferStats {
|
||||
bytes: number;
|
||||
sourceReadSpanMs: number;
|
||||
destWriteSpanMs: number;
|
||||
destWriteKind: "sftp" | "local";
|
||||
}
|
||||
|
||||
export function createEmptyXferStats(): PipelinedXferStats {
|
||||
return {
|
||||
bytes: 0,
|
||||
sourceReadSpanMs: 0,
|
||||
destWriteSpanMs: 0,
|
||||
destWriteKind: "sftp",
|
||||
};
|
||||
}
|
||||
|
||||
export function mergeXferStats(
|
||||
target: PipelinedXferStats,
|
||||
source: PipelinedXferStats,
|
||||
): void {
|
||||
target.bytes += source.bytes;
|
||||
target.sourceReadSpanMs += source.sourceReadSpanMs;
|
||||
target.destWriteSpanMs += source.destWriteSpanMs;
|
||||
target.destWriteKind = source.destWriteKind;
|
||||
}
|
||||
|
||||
export function buildTransferHopTimings(
|
||||
stats: PipelinedXferStats,
|
||||
transferMs: number,
|
||||
): Pick<TransferTimings, "transferBytes" | "endToEndMbPerSec" | "hops"> {
|
||||
const hops: TransferHopMetrics[] = [];
|
||||
|
||||
const sourceRate = computeTransferMbPerSec(
|
||||
stats.bytes,
|
||||
stats.sourceReadSpanMs,
|
||||
);
|
||||
if (sourceRate !== undefined) {
|
||||
hops.push({
|
||||
id: "source_read",
|
||||
bytes: stats.bytes,
|
||||
spanMs: stats.sourceReadSpanMs,
|
||||
mbPerSec: sourceRate,
|
||||
});
|
||||
}
|
||||
|
||||
const destHopId: TransferHopId =
|
||||
stats.destWriteKind === "local" ? "dest_local_write" : "dest_sftp_write";
|
||||
const destRate = computeTransferMbPerSec(stats.bytes, stats.destWriteSpanMs);
|
||||
if (destRate !== undefined) {
|
||||
hops.push({
|
||||
id: destHopId,
|
||||
bytes: stats.bytes,
|
||||
spanMs: stats.destWriteSpanMs,
|
||||
mbPerSec: destRate,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
transferBytes: stats.bytes,
|
||||
endToEndMbPerSec: computeTransferMbPerSec(stats.bytes, transferMs),
|
||||
hops,
|
||||
};
|
||||
}
|
||||
@@ -1,387 +0,0 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
export interface TransferPerformanceProfile {
|
||||
throughputBps: number;
|
||||
failureRate: number;
|
||||
samples: number;
|
||||
preferredLanes: number;
|
||||
pipelineConcurrency: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
export interface TransferTuning {
|
||||
parallelSegmentCount: number;
|
||||
pipelineConcurrency: number;
|
||||
}
|
||||
|
||||
export interface DirectRouteProfile {
|
||||
directMs: number;
|
||||
relayMs: number;
|
||||
failureRate: number;
|
||||
benchmarkSamples: number;
|
||||
outcomeSamples: number;
|
||||
benchmarkedAt: number;
|
||||
cooldownUntil?: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
const MB = 1024 * 1024;
|
||||
const PROFILE_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
const MAX_PROFILES = 128;
|
||||
const STORE_VERSION = 1;
|
||||
const STORE_FILENAME = "adaptive-transfer-profiles.json";
|
||||
const profiles = new Map<string, TransferPerformanceProfile>();
|
||||
const directRoutes = new Map<string, DirectRouteProfile>();
|
||||
let loadedPath: string | undefined;
|
||||
let loadPromise: Promise<void> | undefined;
|
||||
let persistTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let persistPromise = Promise.resolve();
|
||||
|
||||
interface PersistedProfiles {
|
||||
version: typeof STORE_VERSION;
|
||||
profiles: Record<string, TransferPerformanceProfile>;
|
||||
directRoutes: Record<string, DirectRouteProfile>;
|
||||
}
|
||||
|
||||
function validDirectRoute(value: unknown): value is DirectRouteProfile {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
const profile = value as Partial<DirectRouteProfile>;
|
||||
return (
|
||||
Number.isFinite(profile.directMs) &&
|
||||
Number(profile.directMs) > 0 &&
|
||||
Number.isFinite(profile.relayMs) &&
|
||||
Number(profile.relayMs) > 0 &&
|
||||
Number.isFinite(profile.failureRate) &&
|
||||
Number(profile.failureRate) >= 0 &&
|
||||
Number(profile.failureRate) <= 1 &&
|
||||
Number.isFinite(profile.benchmarkSamples) &&
|
||||
Number(profile.benchmarkSamples) > 0 &&
|
||||
Number.isFinite(profile.outcomeSamples) &&
|
||||
Number(profile.outcomeSamples) >= 0 &&
|
||||
Number.isFinite(profile.benchmarkedAt) &&
|
||||
(profile.cooldownUntil === undefined ||
|
||||
Number.isFinite(profile.cooldownUntil)) &&
|
||||
Number.isFinite(profile.updatedAt)
|
||||
);
|
||||
}
|
||||
|
||||
function storePath(): string {
|
||||
const dataDir =
|
||||
process.env.DATA_DIR || path.join(process.cwd(), "db", "data");
|
||||
return path.join(dataDir, STORE_FILENAME);
|
||||
}
|
||||
|
||||
function profileId(key: string): string {
|
||||
return createHash("sha256").update(key).digest("hex");
|
||||
}
|
||||
|
||||
function validProfile(value: unknown): value is TransferPerformanceProfile {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
const profile = value as Partial<TransferPerformanceProfile>;
|
||||
return (
|
||||
Number.isFinite(profile.throughputBps) &&
|
||||
Number(profile.throughputBps) >= 0 &&
|
||||
Number.isFinite(profile.failureRate) &&
|
||||
Number(profile.failureRate) >= 0 &&
|
||||
Number(profile.failureRate) <= 1 &&
|
||||
Number.isFinite(profile.samples) &&
|
||||
Number(profile.samples) > 0 &&
|
||||
Number.isFinite(profile.preferredLanes) &&
|
||||
Number.isFinite(profile.pipelineConcurrency) &&
|
||||
Number.isFinite(profile.updatedAt)
|
||||
);
|
||||
}
|
||||
|
||||
function trimProfiles(now = Date.now()): void {
|
||||
for (const [key, profile] of profiles) {
|
||||
if (now - profile.updatedAt > PROFILE_TTL_MS) profiles.delete(key);
|
||||
}
|
||||
const recent = [...profiles.entries()]
|
||||
.sort(([, a], [, b]) => b.updatedAt - a.updatedAt)
|
||||
.slice(0, MAX_PROFILES);
|
||||
profiles.clear();
|
||||
for (const [key, profile] of recent) profiles.set(key, profile);
|
||||
|
||||
for (const [key, profile] of directRoutes) {
|
||||
if (now - profile.updatedAt > PROFILE_TTL_MS) directRoutes.delete(key);
|
||||
}
|
||||
const recentRoutes = [...directRoutes.entries()]
|
||||
.sort(([, a], [, b]) => b.updatedAt - a.updatedAt)
|
||||
.slice(0, MAX_PROFILES);
|
||||
directRoutes.clear();
|
||||
for (const [key, profile] of recentRoutes) directRoutes.set(key, profile);
|
||||
}
|
||||
|
||||
async function persistProfiles(): Promise<void> {
|
||||
trimProfiles();
|
||||
const target = storePath();
|
||||
const temporary = `${target}.${process.pid}.tmp`;
|
||||
const payload: PersistedProfiles = {
|
||||
version: STORE_VERSION,
|
||||
profiles: Object.fromEntries(profiles),
|
||||
directRoutes: Object.fromEntries(directRoutes),
|
||||
};
|
||||
try {
|
||||
await fs.mkdir(path.dirname(target), { recursive: true });
|
||||
await fs.writeFile(temporary, JSON.stringify(payload), {
|
||||
encoding: "utf8",
|
||||
mode: 0o600,
|
||||
});
|
||||
await fs.rename(temporary, target);
|
||||
} catch {
|
||||
await fs.rm(temporary, { force: true }).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
function queuePersist(): void {
|
||||
if (persistTimer) clearTimeout(persistTimer);
|
||||
persistTimer = setTimeout(() => {
|
||||
persistTimer = undefined;
|
||||
persistPromise = persistPromise.then(persistProfiles);
|
||||
}, 250);
|
||||
persistTimer.unref?.();
|
||||
}
|
||||
|
||||
export async function initializeTransferProfiles(): Promise<void> {
|
||||
const target = storePath();
|
||||
if (loadedPath === target) return loadPromise;
|
||||
loadedPath = target;
|
||||
loadPromise = (async () => {
|
||||
profiles.clear();
|
||||
directRoutes.clear();
|
||||
try {
|
||||
const parsed = JSON.parse(
|
||||
await fs.readFile(target, "utf8"),
|
||||
) as Partial<PersistedProfiles>;
|
||||
if (parsed.version !== STORE_VERSION || !parsed.profiles) return;
|
||||
for (const [key, profile] of Object.entries(parsed.profiles)) {
|
||||
if (/^[a-f0-9]{64}$/.test(key) && validProfile(profile)) {
|
||||
profiles.set(key, profile);
|
||||
}
|
||||
}
|
||||
for (const [key, profile] of Object.entries(parsed.directRoutes ?? {})) {
|
||||
if (/^[a-f0-9]{64}$/.test(key) && validDirectRoute(profile)) {
|
||||
directRoutes.set(key, profile);
|
||||
}
|
||||
}
|
||||
trimProfiles();
|
||||
} catch {
|
||||
// Missing or malformed local learning data must not affect transfers.
|
||||
}
|
||||
})();
|
||||
return loadPromise;
|
||||
}
|
||||
|
||||
export async function flushTransferProfiles(): Promise<void> {
|
||||
if (persistTimer) {
|
||||
clearTimeout(persistTimer);
|
||||
persistTimer = undefined;
|
||||
}
|
||||
persistPromise = persistPromise.then(persistProfiles);
|
||||
await persistPromise;
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.max(min, Math.min(max, Math.floor(value)));
|
||||
}
|
||||
|
||||
export function selectTransferTuning(
|
||||
fileSize: number,
|
||||
profile?: TransferPerformanceProfile,
|
||||
requestedLanes?: number,
|
||||
): TransferTuning {
|
||||
if (fileSize < 32 * MB) {
|
||||
return { parallelSegmentCount: 1, pipelineConcurrency: 8 };
|
||||
}
|
||||
|
||||
let lanes = fileSize >= 1024 * MB ? 4 : 2;
|
||||
let pipelineConcurrency = fileSize >= 256 * MB ? 32 : 16;
|
||||
|
||||
if (profile) {
|
||||
lanes = profile.preferredLanes;
|
||||
pipelineConcurrency = profile.pipelineConcurrency;
|
||||
if (profile.failureRate >= 0.2) {
|
||||
lanes = Math.min(lanes, 2);
|
||||
pipelineConcurrency = Math.min(pipelineConcurrency, 16);
|
||||
}
|
||||
}
|
||||
|
||||
if (requestedLanes !== undefined) lanes = requestedLanes;
|
||||
|
||||
const segmentCapacity = Math.max(1, Math.ceil(fileSize / (256 * MB)));
|
||||
return {
|
||||
parallelSegmentCount: clamp(lanes, 1, Math.min(8, segmentCapacity)),
|
||||
pipelineConcurrency: clamp(pipelineConcurrency, 8, 64),
|
||||
};
|
||||
}
|
||||
|
||||
export function updateTransferProfile(
|
||||
previous: TransferPerformanceProfile | undefined,
|
||||
observation: {
|
||||
bytes: number;
|
||||
durationMs: number;
|
||||
lanes: number;
|
||||
pipelineConcurrency: number;
|
||||
failed: boolean;
|
||||
now?: number;
|
||||
},
|
||||
): TransferPerformanceProfile {
|
||||
const now = observation.now ?? Date.now();
|
||||
const throughputBps =
|
||||
observation.durationMs > 0
|
||||
? (observation.bytes * 1000) / observation.durationMs
|
||||
: 0;
|
||||
const weight = previous ? 0.25 : 1;
|
||||
const smoothedThroughput = previous
|
||||
? previous.throughputBps * (1 - weight) + throughputBps * weight
|
||||
: throughputBps;
|
||||
const failure = observation.failed ? 1 : 0;
|
||||
const failureRate = previous
|
||||
? previous.failureRate * (1 - weight) + failure * weight
|
||||
: failure;
|
||||
|
||||
let preferredLanes = observation.lanes;
|
||||
let pipelineConcurrency = observation.pipelineConcurrency;
|
||||
if (failureRate >= 0.2) {
|
||||
preferredLanes = Math.max(1, Math.floor(preferredLanes / 2));
|
||||
pipelineConcurrency = Math.max(8, Math.floor(pipelineConcurrency / 2));
|
||||
} else if (
|
||||
!observation.failed &&
|
||||
previous &&
|
||||
previous.samples >= 2 &&
|
||||
throughputBps > previous.throughputBps * 1.25
|
||||
) {
|
||||
preferredLanes = Math.min(8, preferredLanes + 1);
|
||||
pipelineConcurrency = Math.min(64, pipelineConcurrency + 8);
|
||||
}
|
||||
|
||||
return {
|
||||
throughputBps: smoothedThroughput,
|
||||
failureRate,
|
||||
samples: (previous?.samples ?? 0) + 1,
|
||||
preferredLanes,
|
||||
pipelineConcurrency,
|
||||
updatedAt: now,
|
||||
};
|
||||
}
|
||||
|
||||
export function getTransferProfile(
|
||||
key: string,
|
||||
now = Date.now(),
|
||||
): TransferPerformanceProfile | undefined {
|
||||
const id = profileId(key);
|
||||
const profile = profiles.get(id);
|
||||
if (!profile) return undefined;
|
||||
if (now - profile.updatedAt <= PROFILE_TTL_MS) return profile;
|
||||
profiles.delete(id);
|
||||
queuePersist();
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function recordTransferProfile(
|
||||
key: string,
|
||||
observation: Parameters<typeof updateTransferProfile>[1],
|
||||
): TransferPerformanceProfile {
|
||||
const profile = updateTransferProfile(getTransferProfile(key), observation);
|
||||
profiles.set(profileId(key), profile);
|
||||
trimProfiles(observation.now);
|
||||
queuePersist();
|
||||
return profile;
|
||||
}
|
||||
|
||||
export function getDirectRouteProfile(
|
||||
key: string,
|
||||
now = Date.now(),
|
||||
): DirectRouteProfile | undefined {
|
||||
const id = profileId(key);
|
||||
const profile = directRoutes.get(id);
|
||||
if (!profile) return undefined;
|
||||
if (now - profile.updatedAt <= PROFILE_TTL_MS) return profile;
|
||||
directRoutes.delete(id);
|
||||
queuePersist();
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function recordDirectRouteBenchmark(
|
||||
key: string,
|
||||
directMs: number,
|
||||
relayMs: number,
|
||||
now = Date.now(),
|
||||
): DirectRouteProfile {
|
||||
const previous = getDirectRouteProfile(key, now);
|
||||
const weight = previous ? 0.25 : 1;
|
||||
const profile: DirectRouteProfile = {
|
||||
directMs: previous
|
||||
? previous.directMs * (1 - weight) + directMs * weight
|
||||
: directMs,
|
||||
relayMs: previous
|
||||
? previous.relayMs * (1 - weight) + relayMs * weight
|
||||
: relayMs,
|
||||
failureRate: previous?.failureRate ?? 0,
|
||||
benchmarkSamples: (previous?.benchmarkSamples ?? 0) + 1,
|
||||
outcomeSamples: previous?.outcomeSamples ?? 0,
|
||||
benchmarkedAt: now,
|
||||
cooldownUntil: previous?.cooldownUntil,
|
||||
updatedAt: now,
|
||||
};
|
||||
directRoutes.set(profileId(key), profile);
|
||||
trimProfiles(now);
|
||||
queuePersist();
|
||||
return profile;
|
||||
}
|
||||
|
||||
export function recordDirectRouteOutcome(
|
||||
key: string,
|
||||
failed: boolean,
|
||||
now = Date.now(),
|
||||
cooldownMs = 10 * 60 * 1000,
|
||||
): DirectRouteProfile | undefined {
|
||||
const previous = getDirectRouteProfile(key, now);
|
||||
if (!previous) return undefined;
|
||||
const failure = failed ? 1 : 0;
|
||||
const weight = previous.outcomeSamples > 0 ? 0.25 : 1;
|
||||
const profile = {
|
||||
...previous,
|
||||
failureRate: previous.failureRate * (1 - weight) + failure * weight,
|
||||
outcomeSamples: previous.outcomeSamples + 1,
|
||||
cooldownUntil: failed ? now + cooldownMs : undefined,
|
||||
updatedAt: now,
|
||||
};
|
||||
directRoutes.set(profileId(key), profile);
|
||||
queuePersist();
|
||||
return profile;
|
||||
}
|
||||
|
||||
export function getRecentDirectRouteDecision(
|
||||
key: string,
|
||||
maxAgeMs: number,
|
||||
now = Date.now(),
|
||||
): { useDirect: boolean; directMs: number; relayMs: number } | undefined {
|
||||
const profile = getDirectRouteProfile(key, now);
|
||||
if (!profile) return undefined;
|
||||
if ((profile.cooldownUntil ?? 0) > now) {
|
||||
return {
|
||||
useDirect: false,
|
||||
directMs: profile.directMs,
|
||||
relayMs: profile.relayMs,
|
||||
};
|
||||
}
|
||||
if (now - profile.benchmarkedAt > maxAgeMs) return undefined;
|
||||
return {
|
||||
useDirect:
|
||||
profile.failureRate < 0.2 && profile.directMs <= profile.relayMs * 0.8,
|
||||
directMs: profile.directMs,
|
||||
relayMs: profile.relayMs,
|
||||
};
|
||||
}
|
||||
|
||||
export function clearTransferProfiles(): void {
|
||||
if (persistTimer) clearTimeout(persistTimer);
|
||||
persistTimer = undefined;
|
||||
loadedPath = undefined;
|
||||
loadPromise = undefined;
|
||||
profiles.clear();
|
||||
directRoutes.clear();
|
||||
}
|
||||
@@ -1,234 +0,0 @@
|
||||
import crypto from "node:crypto";
|
||||
import path from "node:path";
|
||||
import type { SFTPWrapper, Stats } from "ssh2";
|
||||
|
||||
export interface TrashItem {
|
||||
id: string;
|
||||
name: string;
|
||||
originalPath: string;
|
||||
isDirectory: boolean;
|
||||
deletedAt: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
type StoredTrashItem = TrashItem & { trashPath: string };
|
||||
|
||||
const TRASH_DIR = ".termix-trash";
|
||||
const ID_PATTERN = /^[0-9a-f-]{36}$/i;
|
||||
|
||||
function call<T>(
|
||||
run: (done: (error: Error | undefined, value: T) => void) => void,
|
||||
) {
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
run((error, value) => (error ? reject(error) : resolve(value)));
|
||||
});
|
||||
}
|
||||
|
||||
function stat(sftp: SFTPWrapper, target: string) {
|
||||
return call<Stats>((done) => sftp.stat(target, done));
|
||||
}
|
||||
|
||||
function lstat(sftp: SFTPWrapper, target: string) {
|
||||
return call<Stats>((done) => sftp.lstat(target, done));
|
||||
}
|
||||
|
||||
function readdir(sftp: SFTPWrapper, target: string) {
|
||||
return call<import("ssh2").FileEntry[]>((done) => sftp.readdir(target, done));
|
||||
}
|
||||
|
||||
function readFile(sftp: SFTPWrapper, target: string) {
|
||||
return call<Buffer>((done) => sftp.readFile(target, done));
|
||||
}
|
||||
|
||||
function writeFile(sftp: SFTPWrapper, target: string, data: string) {
|
||||
return call<void>((done) => sftp.writeFile(target, data, done));
|
||||
}
|
||||
|
||||
function rename(sftp: SFTPWrapper, from: string, to: string) {
|
||||
return call<void>((done) => sftp.rename(from, to, done));
|
||||
}
|
||||
|
||||
function unlink(sftp: SFTPWrapper, target: string) {
|
||||
return call<void>((done) => sftp.unlink(target, done));
|
||||
}
|
||||
|
||||
function mkdir(sftp: SFTPWrapper, target: string) {
|
||||
return call<void>((done) => sftp.mkdir(target, done));
|
||||
}
|
||||
|
||||
function rmdir(sftp: SFTPWrapper, target: string) {
|
||||
return call<void>((done) => sftp.rmdir(target, done));
|
||||
}
|
||||
|
||||
async function exists(sftp: SFTPWrapper, target: string) {
|
||||
try {
|
||||
await stat(sftp, target);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureDirectory(sftp: SFTPWrapper, target: string) {
|
||||
const normalized = target.replace(/\\/g, "/");
|
||||
const root = normalized.startsWith("/") ? "/" : "";
|
||||
const parts = normalized.split("/").filter(Boolean);
|
||||
let current = root;
|
||||
for (const part of parts) {
|
||||
current =
|
||||
current === "/" ? `/${part}` : current ? `${current}/${part}` : part;
|
||||
if (await exists(sftp, current)) continue;
|
||||
await mkdir(sftp, current);
|
||||
}
|
||||
}
|
||||
|
||||
async function removeTree(sftp: SFTPWrapper, target: string): Promise<void> {
|
||||
const targetStat = await lstat(sftp, target);
|
||||
if (!targetStat.isDirectory()) {
|
||||
await unlink(sftp, target);
|
||||
return;
|
||||
}
|
||||
for (const entry of await readdir(sftp, target)) {
|
||||
if (entry.filename === "." || entry.filename === "..") continue;
|
||||
await removeTree(sftp, path.posix.join(target, entry.filename));
|
||||
}
|
||||
await rmdir(sftp, target);
|
||||
}
|
||||
|
||||
async function trashPaths(sftp: SFTPWrapper) {
|
||||
const home = await call<string>((done) => sftp.realpath(".", done));
|
||||
const root = path.posix.join(home.replace(/\\/g, "/"), TRASH_DIR);
|
||||
const files = path.posix.join(root, "files");
|
||||
const info = path.posix.join(root, "info");
|
||||
await ensureDirectory(sftp, files);
|
||||
await ensureDirectory(sftp, info);
|
||||
return { root, files, info };
|
||||
}
|
||||
|
||||
export function isSafeTrashSource(itemPath: string, trashRoot: string) {
|
||||
const normalized = path.posix.normalize(itemPath.replace(/\\/g, "/"));
|
||||
const root = path.posix.normalize(trashRoot);
|
||||
return (
|
||||
normalized !== "." &&
|
||||
normalized !== "/" &&
|
||||
!/^[A-Za-z]:\/?$/.test(normalized) &&
|
||||
normalized !== root &&
|
||||
!normalized.startsWith(`${root}/`)
|
||||
);
|
||||
}
|
||||
|
||||
function publicItem(item: StoredTrashItem): TrashItem {
|
||||
const { trashPath: _trashPath, ...result } = item;
|
||||
return result;
|
||||
}
|
||||
|
||||
async function readStoredItem(
|
||||
sftp: SFTPWrapper,
|
||||
dirs: { root: string; files: string; info: string },
|
||||
id: string,
|
||||
): Promise<StoredTrashItem> {
|
||||
if (!ID_PATTERN.test(id)) throw new Error("Invalid trash item id");
|
||||
const parsed = JSON.parse(
|
||||
(await readFile(sftp, path.posix.join(dirs.info, `${id}.json`))).toString(
|
||||
"utf8",
|
||||
),
|
||||
) as StoredTrashItem;
|
||||
if (
|
||||
parsed.id !== id ||
|
||||
parsed.trashPath !== path.posix.join(dirs.files, id) ||
|
||||
!isSafeTrashSource(parsed.originalPath, dirs.root)
|
||||
) {
|
||||
throw new Error("Invalid trash metadata");
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export async function moveToTrash(
|
||||
sftp: SFTPWrapper,
|
||||
itemPath: string,
|
||||
): Promise<TrashItem> {
|
||||
const dirs = await trashPaths(sftp);
|
||||
if (!isSafeTrashSource(itemPath, dirs.root)) {
|
||||
throw new Error("This path cannot be moved to trash");
|
||||
}
|
||||
const itemStat = await lstat(sftp, itemPath);
|
||||
const id = crypto.randomUUID();
|
||||
const trashPath = path.posix.join(dirs.files, id);
|
||||
const item: StoredTrashItem = {
|
||||
id,
|
||||
name: path.posix.basename(itemPath.replace(/\\/g, "/")),
|
||||
originalPath: itemPath,
|
||||
trashPath,
|
||||
isDirectory: itemStat.isDirectory(),
|
||||
deletedAt: new Date().toISOString(),
|
||||
size: itemStat.size,
|
||||
};
|
||||
|
||||
await rename(sftp, itemPath, trashPath);
|
||||
try {
|
||||
await writeFile(
|
||||
sftp,
|
||||
path.posix.join(dirs.info, `${id}.json`),
|
||||
JSON.stringify(item),
|
||||
);
|
||||
} catch (error) {
|
||||
await rename(sftp, trashPath, itemPath).catch(() => {});
|
||||
throw error;
|
||||
}
|
||||
return publicItem(item);
|
||||
}
|
||||
|
||||
export async function listTrash(
|
||||
sftp: SFTPWrapper,
|
||||
retentionDays: number,
|
||||
): Promise<TrashItem[]> {
|
||||
const dirs = await trashPaths(sftp);
|
||||
const cutoff = Date.now() - retentionDays * 86_400_000;
|
||||
const items: TrashItem[] = [];
|
||||
for (const entry of await readdir(sftp, dirs.info)) {
|
||||
if (!entry.filename.endsWith(".json")) continue;
|
||||
const id = entry.filename.slice(0, -5);
|
||||
try {
|
||||
const item = await readStoredItem(sftp, dirs, id);
|
||||
if (new Date(item.deletedAt).getTime() < cutoff) {
|
||||
if (await exists(sftp, item.trashPath))
|
||||
await removeTree(sftp, item.trashPath);
|
||||
await unlink(sftp, path.posix.join(dirs.info, entry.filename));
|
||||
continue;
|
||||
}
|
||||
if (await exists(sftp, item.trashPath)) items.push(publicItem(item));
|
||||
else await unlink(sftp, path.posix.join(dirs.info, entry.filename));
|
||||
} catch {
|
||||
// Ignore corrupt metadata without exposing arbitrary paths to deletion.
|
||||
}
|
||||
}
|
||||
return items.sort((a, b) => b.deletedAt.localeCompare(a.deletedAt));
|
||||
}
|
||||
|
||||
export async function restoreTrashItem(sftp: SFTPWrapper, id: string) {
|
||||
const dirs = await trashPaths(sftp);
|
||||
const item = await readStoredItem(sftp, dirs, id);
|
||||
if (await exists(sftp, item.originalPath)) {
|
||||
throw new Error("A file already exists at the original path");
|
||||
}
|
||||
await rename(sftp, item.trashPath, item.originalPath);
|
||||
await unlink(sftp, path.posix.join(dirs.info, `${id}.json`));
|
||||
return publicItem(item);
|
||||
}
|
||||
|
||||
export async function permanentlyDeleteTrashItem(
|
||||
sftp: SFTPWrapper,
|
||||
id: string,
|
||||
) {
|
||||
const dirs = await trashPaths(sftp);
|
||||
const item = await readStoredItem(sftp, dirs, id);
|
||||
if (await exists(sftp, item.trashPath))
|
||||
await removeTree(sftp, item.trashPath);
|
||||
await unlink(sftp, path.posix.join(dirs.info, `${id}.json`));
|
||||
}
|
||||
|
||||
export async function emptyTrash(sftp: SFTPWrapper) {
|
||||
const items = await listTrash(sftp, 365_000);
|
||||
for (const item of items) await permanentlyDeleteTrashItem(sftp, item.id);
|
||||
return items.length;
|
||||
}
|
||||
@@ -1,164 +0,0 @@
|
||||
export function isExecutableFile(
|
||||
permissions: string,
|
||||
fileName: string,
|
||||
): boolean {
|
||||
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)
|
||||
);
|
||||
}
|
||||
|
||||
export function modeToPermissions(mode: number): string {
|
||||
const S_IFDIR = 0o040000;
|
||||
const S_IFLNK = 0o120000;
|
||||
const S_IFMT = 0o170000;
|
||||
|
||||
const type = mode & S_IFMT;
|
||||
const prefix = type === S_IFDIR ? "d" : type === S_IFLNK ? "l" : "-";
|
||||
|
||||
const perms = [
|
||||
mode & 0o400 ? "r" : "-",
|
||||
mode & 0o200 ? "w" : "-",
|
||||
mode & 0o100 ? "x" : "-",
|
||||
mode & 0o040 ? "r" : "-",
|
||||
mode & 0o020 ? "w" : "-",
|
||||
mode & 0o010 ? "x" : "-",
|
||||
mode & 0o004 ? "r" : "-",
|
||||
mode & 0o002 ? "w" : "-",
|
||||
mode & 0o001 ? "x" : "-",
|
||||
].join("");
|
||||
|
||||
return prefix + perms;
|
||||
}
|
||||
|
||||
export function parseLsDateToTimestamp(dateStr: string): number {
|
||||
const parts = dateStr.trim().split(/\s+/);
|
||||
if (parts.length < 3) return 0;
|
||||
|
||||
const [month, day, timeOrYear] = parts;
|
||||
const monthIndex = [
|
||||
"Jan",
|
||||
"Feb",
|
||||
"Mar",
|
||||
"Apr",
|
||||
"May",
|
||||
"Jun",
|
||||
"Jul",
|
||||
"Aug",
|
||||
"Sep",
|
||||
"Oct",
|
||||
"Nov",
|
||||
"Dec",
|
||||
].indexOf(month);
|
||||
if (monthIndex === -1) return 0;
|
||||
|
||||
const dayNum = parseInt(day, 10);
|
||||
const now = new Date();
|
||||
|
||||
if (timeOrYear.includes(":")) {
|
||||
const [hours, minutes] = timeOrYear.split(":").map(Number);
|
||||
let year = now.getFullYear();
|
||||
const candidate = new Date(year, monthIndex, dayNum, hours, minutes);
|
||||
if (candidate > now) year -= 1;
|
||||
return new Date(year, monthIndex, dayNum, hours, minutes).getTime() / 1000;
|
||||
}
|
||||
|
||||
const year = parseInt(timeOrYear, 10);
|
||||
if (isNaN(year)) return 0;
|
||||
return new Date(year, monthIndex, dayNum).getTime() / 1000;
|
||||
}
|
||||
|
||||
export function formatMtime(mtime: number): string {
|
||||
const date = new Date(mtime * 1000);
|
||||
const months = [
|
||||
"Jan",
|
||||
"Feb",
|
||||
"Mar",
|
||||
"Apr",
|
||||
"May",
|
||||
"Jun",
|
||||
"Jul",
|
||||
"Aug",
|
||||
"Sep",
|
||||
"Oct",
|
||||
"Nov",
|
||||
"Dec",
|
||||
];
|
||||
const month = months[date.getMonth()];
|
||||
const day = date.getDate().toString().padStart(2, " ");
|
||||
const now = new Date();
|
||||
const sixMonthsAgo = new Date(now.getTime() - 180 * 24 * 60 * 60 * 1000);
|
||||
|
||||
if (date > sixMonthsAgo) {
|
||||
const hours = date.getHours().toString().padStart(2, "0");
|
||||
const minutes = date.getMinutes().toString().padStart(2, "0");
|
||||
return `${month} ${day} ${hours}:${minutes}`;
|
||||
}
|
||||
return `${month} ${day} ${date.getFullYear()}`;
|
||||
}
|
||||
|
||||
export 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";
|
||||
}
|
||||
|
||||
export function detectBinary(buffer: Buffer): boolean {
|
||||
if (buffer.length === 0) return false;
|
||||
|
||||
const sampleSize = Math.min(buffer.length, 8192);
|
||||
let nullBytes = 0;
|
||||
|
||||
for (let i = 0; i < sampleSize; i++) {
|
||||
const byte = buffer[i];
|
||||
|
||||
if (byte === 0) {
|
||||
nullBytes++;
|
||||
}
|
||||
|
||||
if (byte < 32 && byte !== 9 && byte !== 10 && byte !== 13) {
|
||||
if (++nullBytes > 1) return true;
|
||||
}
|
||||
}
|
||||
|
||||
return nullBytes / sampleSize > 0.01;
|
||||
}
|
||||
@@ -109,13 +109,6 @@ export async function resolveHostById(
|
||||
host.jumpHosts = [];
|
||||
}
|
||||
}
|
||||
if (typeof host.tunnelConnections === "string") {
|
||||
try {
|
||||
host.tunnelConnections = JSON.parse(host.tunnelConnections as string);
|
||||
} catch {
|
||||
host.tunnelConnections = [];
|
||||
}
|
||||
}
|
||||
if (typeof host.terminalConfig === "string" && host.terminalConfig) {
|
||||
try {
|
||||
host.terminalConfig = JSON.parse(host.terminalConfig as string);
|
||||
|
||||
@@ -68,6 +68,8 @@ export interface HostStatusDeps {
|
||||
userId?: string;
|
||||
hostIds?: number[];
|
||||
}) => Promise<StatusTarget[]>;
|
||||
/** Hosts other users shared with this one, directly or through a role. */
|
||||
loadSharedHostIds?: (userId: string) => Promise<number[]>;
|
||||
ping: (host: string, port: number) => Promise<boolean>;
|
||||
pingThroughJumpHosts: (
|
||||
target: StatusTarget,
|
||||
@@ -110,6 +112,17 @@ const defaultDeps: HostStatusDeps = {
|
||||
(
|
||||
await createCurrentHostResolutionRepository().listStatusTargets(filter)
|
||||
).map(toStatusTarget),
|
||||
loadSharedHostIds: async (userId) => {
|
||||
const { createCurrentRbacAccessRepository, createCurrentRoleRepository } =
|
||||
await import("../../database/repositories/factory.js");
|
||||
const roleIds = await createCurrentRoleRepository().listUserRoleIds(userId);
|
||||
const entries =
|
||||
await createCurrentRbacAccessRepository().listVisibleHostAccessEntries(
|
||||
userId,
|
||||
roleIds,
|
||||
);
|
||||
return [...new Set(entries.map((entry) => entry.hostId))];
|
||||
},
|
||||
ping: (host, port) => tcpPing(host, port, 5000),
|
||||
pingThroughJumpHosts: async (target, port) => {
|
||||
const { createJumpHostChain } = await import("../jump-host-chain.js");
|
||||
@@ -266,6 +279,35 @@ export class HostStatusService {
|
||||
for (const target of targets) {
|
||||
if (!this.polled.has(target.id)) this.startPolling(target);
|
||||
}
|
||||
await this.startShared(userId, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hosts shared with the user are checked as their owners, even when no
|
||||
* owner has asked for statuses since the server started.
|
||||
*/
|
||||
private async startShared(
|
||||
userId: string,
|
||||
allowed: Set<number> | null,
|
||||
): Promise<void> {
|
||||
if (!this.deps.loadSharedHostIds) return;
|
||||
try {
|
||||
const shared = (await this.deps.loadSharedHostIds(userId)).filter(
|
||||
(hostId) =>
|
||||
!this.polled.has(hostId) && (allowed === null || allowed.has(hostId)),
|
||||
);
|
||||
if (shared.length === 0) return;
|
||||
const targets = await this.deps.loadTargets({ hostIds: shared });
|
||||
for (const target of targets) {
|
||||
if (!this.polled.has(target.id)) this.startPolling(target);
|
||||
}
|
||||
} catch (error) {
|
||||
sshLogger.warn("Could not start status checks for shared hosts", {
|
||||
operation: "host_status_shared",
|
||||
userId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async reconcile(userId: string, allowed: Set<number>): Promise<void> {
|
||||
@@ -278,6 +320,7 @@ export class HostStatusService {
|
||||
}
|
||||
if (!this.polled.has(target.id)) this.startPolling(target);
|
||||
}
|
||||
await this.startShared(userId, allowed);
|
||||
}
|
||||
|
||||
private async reload(hostId: number): Promise<void> {
|
||||
|
||||
Reference in new issue
Block a user