Files
Termix/plugins/file-manager/src/backend/index.ts
T

2375 lines
71 KiB
TypeScript

import express, { type Router } from "express";
import cookieParser from "cookie-parser";
import { Client as SSHClient } from "ssh2";
import type {
PluginContext,
PluginSshHost,
PluginSshPromptChannel,
} from "@termix/plugin-sdk/backend";
import { getErrorMessage } from "./error-message.js";
import { setPluginSsh, pluginSsh } from "./ssh.js";
import { setPluginCtx } from "./plugin-ctx.js";
import {
flushTransferProfiles,
setTransferProfileStore,
} from "./transfer-tuning.js";
const TRANSFER_PROFILES_KEY = "transfer-profiles";
import {
createConnectionLog,
createFileLogger,
hostAddressMismatch,
HostAddressMismatchError,
HostNotOnThisServerError,
resolveServerHostId,
type ConnectionStage,
type FileLogger,
type LogEntry,
} from "./host-identity.js";
import {
ChannelOpenSerializer,
execChannel,
getSessionSftp,
type PendingTOTPSession,
type SSHSession,
} from "./session.js";
import { registerFileListingRoutes } from "./list-routes.js";
import { registerFileContentRoutes } from "./content-routes.js";
import { registerFileOperationRoutes } from "./operation-routes.js";
import { registerFileDownloadRoutes } from "./download-routes.js";
import { registerFileActionRoutes } from "./action-routes.js";
import { registerBookmarkRoutes } from "./bookmark-routes.js";
import {
startHostTransfer,
getTransferStatus,
listActiveTransfers,
probeHungStreamTransfers,
requestTransferCancel,
cleanupCancelledTransfer,
retryHostTransfer,
previewArchiveTransferMethod,
type HostTransferDeps,
} from "./transfer-engine.js";
import { createFilesService } from "./service.js";
import { tables } from "./tables.js";
let fileLogger: FileLogger = createFileLogger(console);
// Shape of a resolved host as this route reads it; core fills more fields
// than PluginSshHost names.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type LooseHost = PluginSshHost & Record<string, any>;
// Core's jump chain failure, matched by name so no core class is imported.
function isJumpHostChainError(error: unknown): boolean {
return error instanceof Error && error.name === "JumpHostChainError";
}
/** Cosmetic only: whether the proxy step is worth a connection-log line. */
function hasSocks5Config(host: PluginSshHost): boolean {
const chain = Array.isArray(host.socks5ProxyChain)
? host.socks5ProxyChain
: [];
return !!host.useSocks5 && (!!host.socks5Host || chain.length > 0);
}
/**
* The host id came from whichever database the client is displaying. If this
* server has a different machine under that id, refuse rather than browse,
* edit or delete files on the wrong host.
*/
function assertResolvedHost(
clientIp: unknown,
hostSyncId: string | null | undefined,
resolvedHost: { ip?: string } | null | undefined,
hostId: number,
userId: string,
): void {
if (hostSyncId) {
if (resolvedHost) return;
fileLogger.error(
"Refusing SFTP connection: host is not known to this server",
undefined,
{ operation: "file_manager_host_sync_id_unknown", hostId, userId },
);
throw new HostNotOnThisServerError();
}
if (!hostAddressMismatch(clientIp, resolvedHost?.ip)) return;
fileLogger.error(
"Refusing SFTP connection: host id resolves to a different address here",
undefined,
{
operation: "file_manager_host_id_mismatch",
hostId,
userId,
clientIp,
resolvedIp: resolvedHost?.ip,
},
);
throw new HostAddressMismatchError();
}
export async function activate(ctx: PluginContext) {
fileLogger = createFileLogger(ctx.log);
setPluginSsh(ctx.ssh);
setPluginCtx(ctx);
ctx.disposables.add(() => setPluginSsh(null));
ctx.disposables.add(() => setPluginCtx(null));
setTransferProfileStore({
read: () => ctx.kv.get(TRANSFER_PROFILES_KEY),
write: (value) => ctx.kv.set(TRANSFER_PROFILES_KEY, value),
});
ctx.disposables.add(async () => {
await flushTransferProfiles();
setTransferProfileStore(null);
});
/* eslint-disable @typescript-eslint/no-explicit-any */
// ctx.db.define hands back an untyped table object; typed at this module's
// edge, the same way plugins/workspaces/src/backend/repository.ts does.
const table: any = await ctx.db.define(tables[0]);
const pinnedTable: any = await ctx.db.define(tables[1]);
const shortcutsTable: any = await ctx.db.define(tables[2]);
const transferRecentTable: any = await ctx.db.define(tables[3]);
/* eslint-enable @typescript-eslint/no-explicit-any */
// The user row survives a password-reset data wipe (the DEK could not be
// recovered, not that the account is gone), so a refUser() cascade never
// fires for it. Core asks every plugin holding user data to drop its own
// rows on this topic instead.
ctx.events.on("user.data_wiped", (payload) => {
const userId = (payload as { userId?: string } | undefined)?.userId;
if (!userId) return;
void (async () => {
try {
/* eslint-disable @typescript-eslint/no-explicit-any */
const drizzle = await ctx.db.client<any>();
/* eslint-enable @typescript-eslint/no-explicit-any */
const { eq } = await import("drizzle-orm");
await drizzle.delete(table).where(eq(table.userId, userId));
await drizzle.delete(pinnedTable).where(eq(pinnedTable.userId, userId));
await drizzle
.delete(shortcutsTable)
.where(eq(shortcutsTable.userId, userId));
await drizzle
.delete(transferRecentTable)
.where(eq(transferRecentTable.userId, userId));
await ctx.db.persist();
} catch (error) {
ctx.log.error(
"Failed to wipe file manager data for user",
error as Error,
);
}
})();
});
const sshSessions: Record<string, SSHSession> = {};
const pendingTOTPSessions: Record<string, PendingTOTPSession> = {};
const activeListRequests: Record<string, boolean> = {};
function cleanupSession(sessionId: string) {
const session = sshSessions[sessionId];
if (!session) return;
if (session.activeOperations > 0) {
fileLogger.warn(
`Deferring session cleanup for ${sessionId} - ${session.activeOperations} active operations`,
{
operation: "cleanup_deferred",
sessionId,
activeOperations: session.activeOperations,
},
);
scheduleSessionCleanup(sessionId);
return;
}
try {
if (session.sftp) {
session.sftp.end();
session.sftp = undefined;
}
} catch {
// expected
}
try {
session.client.end();
} catch {
// expected
}
clearTimeout(session.timeout);
delete sshSessions[sessionId];
}
function scheduleSessionCleanup(sessionId: string) {
const session = sshSessions[sessionId];
if (!session) return;
if (session.timeout) clearTimeout(session.timeout);
session.timeout = setTimeout(
() => cleanupSession(sessionId),
30 * 60 * 1000,
);
}
// Pending TOTP and browser sign-in parking sessions expire on their own; sweep stale
// ones so a session left mid-handshake doesn't leak an open ssh2 Client.
const pendingSweep = setInterval(() => {
const now = Date.now();
for (const sessionId of Object.keys(pendingTOTPSessions)) {
const session = pendingTOTPSessions[sessionId];
if (now - session.createdAt > 300000) {
try {
session.client.end();
} catch {
// expected
}
delete pendingTOTPSessions[sessionId];
}
}
}, 60000);
ctx.disposables.add(() => clearInterval(pendingSweep));
ctx.disposables.add(() => {
for (const sessionId of Object.keys(sshSessions)) cleanupSession(sessionId);
});
function verifySessionOwnership(
session: SSHSession,
userId: string,
): boolean {
return !session.userId || session.userId === userId;
}
function resolveBrowseHostId(
browseSessionId: string,
browseSession: SSHSession,
): number | undefined {
if (browseSession.hostId) return browseSession.hostId;
const parsed = Number.parseInt(browseSessionId, 10);
return Number.isFinite(parsed) ? parsed : undefined;
}
async function openDedicatedTransferSession(
browseSessionId: string,
dedicatedSessionId: string,
userId: string,
transferId: string,
options?: { allowBrowseDisconnected?: boolean },
): Promise<SSHSession> {
const browseSession = sshSessions[browseSessionId];
if (!options?.allowBrowseDisconnected && !browseSession?.isConnected) {
throw new Error("Browse SSH session not connected");
}
if (browseSession && !verifySessionOwnership(browseSession, userId)) {
throw new Error("Session access denied");
}
const hostId = browseSession
? resolveBrowseHostId(browseSessionId, browseSession)
: (() => {
const parsed = Number.parseInt(browseSessionId, 10);
return Number.isFinite(parsed) ? parsed : undefined;
})();
if (!hostId) {
throw new Error("Cannot open transfer connection: unknown host");
}
const host = await ctx.asUser(userId, () => ctx.ssh.resolveHost(hostId));
if (!host) {
throw new Error("Host not found for transfer connection");
}
const existingSession = sshSessions[dedicatedSessionId];
if (
existingSession?.isConnected &&
verifySessionOwnership(existingSession, userId)
) {
return existingSession;
}
fileLogger.info("Opening dedicated transfer SSH session", {
operation: "transfer_ssh_connect",
transferId,
browseSessionId,
dedicatedSessionId,
hostId,
ip: host.ip,
port: host.port,
username: host.username,
});
const connection = await ctx.ssh.connect<SSHClient>(
host as unknown as PluginSshHost,
{
purpose: "file-transfer",
profile: "stream",
timeoutMs: 60000,
overrides: { tcpKeepAliveInitialDelay: 5000 },
},
);
const client = connection.client;
const session: SSHSession = {
client,
isConnected: true,
lastActive: Date.now(),
activeOperations: 0,
channelOpener: new ChannelOpenSerializer(),
userId,
ip: host.ip,
port: host.port,
hostId: host.id,
username: host.username,
transferDedicated: true,
transferId,
browseSessionId,
};
client.on("close", () => {
fileLogger.info("Dedicated transfer SSH connection closed", {
operation: "transfer_ssh_disconnected",
transferId,
dedicatedSessionId,
browseSessionId,
hostId,
});
const existing = sshSessions[dedicatedSessionId];
if (existing) {
existing.isConnected = false;
closeDedicatedTransferSession(dedicatedSessionId);
}
});
sshSessions[dedicatedSessionId] = session;
return session;
}
function closeDedicatedTransferSession(sessionId: string): void {
const session = sshSessions[sessionId];
if (!session?.transferDedicated) return;
fileLogger.info("Closing dedicated transfer SSH session", {
operation: "transfer_ssh_close",
sessionId,
transferId: session.transferId,
browseSessionId: session.browseSessionId,
});
try {
if (session.sftp) {
session.sftp.end();
session.sftp = undefined;
}
} catch {
// expected
}
session.sftpPending = undefined;
try {
session.client.end();
} catch {
// expected
}
clearTimeout(session.timeout);
delete sshSessions[sessionId];
}
const app = express();
app.use(ctx.rbac.require("use") as never);
app.use(cookieParser());
// uploadFileChunk streams its octet-stream body straight into SFTP; letting
// the JSON/urlencoded parsers run is harmless (they ignore a body they do
// not claim), but the raw parser only applies to that one route so a chunk
// isn't buffered whole in memory first.
const rawBodyParser = express.raw({
limit: "5gb",
type: "application/octet-stream",
});
app.use((req, res, next) => {
if (req.path === "/uploadFileChunk") return rawBodyParser(req, res, next);
next();
});
app.use((_req, res, next) => {
res.setHeader("Cache-Control", "no-store");
next();
});
app.use((req, res, next) => {
if (
req.path === "/connect" ||
req.path === "/connect-totp" ||
req.path === "/connect-browser-sign-in"
) {
return next();
}
const sessionId = (req.query.sessionId as string) || req.body?.sessionId;
if (!sessionId) return next();
const session = sshSessions[sessionId];
if (!session) return next();
const userId = ctx.currentActor();
if (!userId || !verifySessionOwnership(session, userId)) {
return res.status(403).json({ error: "Session access denied" });
}
next();
});
/**
* @openapi
* /plugin-api/file-manager/connect:
* post:
* summary: Connect to SSH for file management
* description: Establishes an SSH/SFTP connection for file manager operations. Supports password, key-based, and keyboard-interactive authentication, as well as jump hosts and SOCKS5 proxies.
* tags:
* - File Manager
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required:
* - sessionId
* - ip
* - port
* - username
* properties:
* sessionId:
* type: string
* hostId:
* type: number
* ip:
* type: string
* port:
* type: number
* username:
* type: string
* password:
* type: string
* sshKey:
* type: string
* keyPassword:
* type: string
* authType:
* type: string
* enum: [password, key, none]
* credentialId:
* type: number
* jumpHosts:
* type: array
* items:
* type: object
* properties:
* hostId:
* type: number
* useSocks5:
* type: boolean
* socks5Host:
* type: string
* socks5Port:
* type: number
* socks5Username:
* type: string
* socks5Password:
* type: string
* socks5ProxyChain:
* type: array
* responses:
* 200:
* description: SSH connection established, or requires TOTP or a browser sign-in.
* 400:
* description: Missing required parameters or invalid SSH key format.
* 401:
* description: Authentication required.
* 500:
* description: SSH connection failed.
*/
app.post("/connect", async (req, res) => {
const {
sessionId,
hostId,
syncId: hostSyncId,
ip,
port,
username,
password,
sshKey,
keyPassword,
authType,
credentialId,
jumpHosts,
useSocks5,
socks5Host,
socks5Port,
socks5Username,
socks5Password,
socks5ProxyChain,
} = req.body;
const userId = ctx.currentActor();
const connectionLogs: Array<Omit<LogEntry, "id" | "timestamp">> = [];
connectionLogs.push(
createConnectionLog(
"info",
"sftp_connecting",
`Initiating SFTP connection to ${username}@${ip}:${port}`,
),
);
if (!userId) {
fileLogger.error("SSH connection rejected: no authenticated user", {
operation: "file_connect_auth",
sessionId,
});
connectionLogs.push(
createConnectionLog(
"error",
"sftp_auth",
"Authentication required - no user session",
),
);
return res
.status(401)
.json({ error: "Authentication required", connectionLogs });
}
if (!sessionId || !ip || !username || !port) {
fileLogger.warn("Missing SSH connection parameters for file manager", {
operation: "file_connect",
sessionId,
hasIp: !!ip,
hasUsername: !!username,
hasPort: !!port,
});
connectionLogs.push(
createConnectionLog(
"error",
"sftp_connecting",
"Missing required connection parameters",
),
);
return res
.status(400)
.json({ error: "Missing SSH connection parameters", connectionLogs });
}
if (sshSessions[sessionId]?.isConnected) {
cleanupSession(sessionId);
}
if (pendingTOTPSessions[sessionId]) {
try {
pendingTOTPSessions[sessionId].client.end();
} catch {
// expected
}
delete pendingTOTPSessions[sessionId];
}
const client = new SSHClient();
connectionLogs.push(
createConnectionLog(
"info",
"sftp_auth",
"Resolving authentication credentials",
),
);
let resolvedCredentials = {
password,
sshKey,
keyPassword,
authType,
sudoPassword: undefined as string | undefined,
certPublicKey: undefined as string | undefined,
};
let resolvedIp = ip;
let resolvedPort = port;
let resolvedUsername = username;
let resolvedSshOptions: PluginSshHost["sshOptions"];
let resolvedJumpHosts = jumpHosts;
let resolvedScpLegacy = false;
let resolvedUseSocks5 = useSocks5;
let resolvedSocks5Host = socks5Host;
let resolvedSocks5Port = socks5Port;
let resolvedSocks5Username = socks5Username;
let resolvedSocks5Password = socks5Password;
let resolvedSocks5ProxyChain = socks5ProxyChain;
const resolveFrom = async () => {
const resolvedHost = (await ctx.ssh.resolveHost(hostId, {
syncId: hostSyncId,
})) as LooseHost | null;
assertResolvedHost(ip, hostSyncId, resolvedHost, hostId, userId);
if (!resolvedHost) return;
resolvedIp = resolvedHost.ip;
resolvedPort = resolvedHost.port;
resolvedUsername = resolvedHost.username;
resolvedCredentials = {
password: resolvedHost.password,
sshKey: resolvedHost.key,
keyPassword: keyPassword || resolvedHost.keyPassword,
authType: resolvedHost.authType,
sudoPassword: resolvedHost.sudoPassword as string | undefined,
certPublicKey: (resolvedHost as { certPublicKey?: string })
.certPublicKey,
};
resolvedSshOptions =
resolvedHost.sshOptions as PluginSshHost["sshOptions"];
resolvedScpLegacy =
(await ctx.settings.getHost<boolean>(
Number(resolvedHost.id),
"scpLegacy",
)) === true;
if (resolvedHost.useSocks5) {
resolvedUseSocks5 = resolvedHost.useSocks5;
resolvedSocks5Host = resolvedHost.socks5Host;
resolvedSocks5Port = resolvedHost.socks5Port;
resolvedSocks5Username = resolvedHost.socks5Username;
resolvedSocks5Password = resolvedHost.socks5Password;
resolvedSocks5ProxyChain = resolvedHost.socks5ProxyChain;
}
if (
(!resolvedJumpHosts || resolvedJumpHosts.length === 0) &&
resolvedHost.jumpHosts &&
resolvedHost.jumpHosts.length > 0
) {
resolvedJumpHosts = resolvedHost.jumpHosts;
connectionLogs.push(
createConnectionLog(
"info",
"jump",
`Loaded ${resolvedHost.jumpHosts.length} jump host(s) from server-side host data`,
),
);
}
connectionLogs.push(
createConnectionLog(
"info",
"sftp_auth",
"Credentials resolved from server-side host data",
),
);
};
if (hostId && userId && !password && !sshKey) {
try {
await resolveFrom();
} catch (error) {
if (
error instanceof HostAddressMismatchError ||
error instanceof HostNotOnThisServerError
)
throw error;
fileLogger.warn(`Failed to resolve host credentials for ${hostId}`, {
operation: "ssh_credentials",
hostId,
error: getErrorMessage(error),
});
}
} else if (credentialId && hostId && userId) {
try {
await resolveFrom();
} catch (error) {
if (
error instanceof HostAddressMismatchError ||
error instanceof HostNotOnThisServerError
)
throw error;
fileLogger.warn(`Failed to resolve credentials for host ${hostId}`, {
operation: "ssh_credentials",
hostId,
credentialId,
error: getErrorMessage(error),
});
}
}
let serverHostId = hostId;
if (hostSyncId && userId) {
const resolvedHost = await ctx.ssh.resolveHost(hostId, {
syncId: hostSyncId,
});
assertResolvedHost(ip, hostSyncId, resolvedHost, hostId, userId);
serverHostId = resolveServerHostId(hostId, resolvedHost) ?? hostId;
}
const effectiveAuthType =
resolvedCredentials.authType ||
(resolvedCredentials.sshKey
? "key"
: resolvedCredentials.password
? "password"
: undefined);
if (!effectiveAuthType) {
fileLogger.warn(
"No valid authentication method provided for file manager",
{ operation: "file_connect", sessionId, hostId },
);
connectionLogs.push(
createConnectionLog(
"error",
"sftp_auth",
"No valid authentication method provided",
),
);
return res.status(400).json({
error: "Either password or SSH key must be provided",
connectionLogs,
});
}
const connectTarget = {
id: serverHostId,
ip: resolvedIp,
port: resolvedPort,
username: resolvedUsername,
userId,
authType: effectiveAuthType,
password: resolvedCredentials.password,
key: resolvedCredentials.sshKey,
keyPassword: resolvedCredentials.keyPassword,
certPublicKey: resolvedCredentials.certPublicKey,
sshOptions: resolvedSshOptions ?? null,
jumpHosts: resolvedJumpHosts,
useSocks5: resolvedUseSocks5,
socks5Host: resolvedSocks5Host,
socks5Port: resolvedSocks5Port,
socks5Username: resolvedSocks5Username,
socks5Password: resolvedSocks5Password,
socks5ProxyChain: resolvedSocks5ProxyChain,
} as PluginSshHost;
const ssh = pluginSsh();
const prepared = await ssh.prepare(connectTarget, {
purpose: "file-manager",
profile: "session",
client,
serverHostId,
log: (level, message) =>
connectionLogs.push(createConnectionLog(level, "sftp_auth", message)),
});
const config = prepared.config as Record<string, unknown>;
if (prepared.outcome.status !== "ready") {
const outcome = prepared.outcome;
if (
outcome.status === "error" &&
outcome.code === "passphrase-required"
) {
return res.json({ status: "passphrase_required", connectionLogs });
}
fileLogger.warn("File manager SSH auth could not be prepared", {
operation: "file_connect",
sessionId,
hostId,
authType: effectiveAuthType,
error: outcome.message,
});
connectionLogs.push(
createConnectionLog("error", "sftp_auth", outcome.message),
);
if (outcome.status === "interaction-required") {
return res.status(401).json({
error: outcome.message,
requiresAuthInteraction: outcome.interaction,
...(outcome.flag ? { [outcome.flag]: true } : {}),
connectionLogs,
});
}
return res.status(400).json({
error:
outcome.status === "error" && outcome.code === "invalid-key"
? "Invalid SSH key format"
: outcome.message,
connectionLogs,
});
}
const credentialless = !ssh.requiresSecret(effectiveAuthType);
let responseSent = false;
connectionLogs.push(
createConnectionLog("info", "dns", `Resolving DNS for ${ip}`),
);
connectionLogs.push(
createConnectionLog("info", "tcp", `Connecting to ${ip}:${port}`),
);
connectionLogs.push(
createConnectionLog("info", "handshake", "Initiating SSH handshake"),
);
connectionLogs.push(
createConnectionLog(
"info",
"sftp_connecting",
"Establishing SSH connection...",
),
);
client.on("ready", () => {
if (responseSent) return;
responseSent = true;
fileLogger.info("File manager SSH connection established", {
operation: "file_ssh_connected",
sessionId,
userId,
hostId: serverHostId,
ip,
port,
username,
});
connectionLogs.push(
createConnectionLog(
"success",
"connected",
"SSH connection established successfully",
),
);
connectionLogs.push(
createConnectionLog(
"success",
"sftp_connected",
"SFTP session established successfully",
),
);
sshSessions[sessionId] = {
client,
isConnected: true,
lastActive: Date.now(),
activeOperations: 0,
channelOpener: new ChannelOpenSerializer(),
userId,
ip,
port,
hostId: serverHostId,
username,
sudoPassword: resolvedCredentials.sudoPassword,
scpLegacy: resolvedScpLegacy,
};
scheduleSessionCleanup(sessionId);
if (userId) {
void (async () => {
await ctx.audit.record({
request: req,
action: "file_manager_connect",
resourceType: "host",
resourceId: serverHostId ? String(serverHostId) : undefined,
resourceName: `${username}@${ip}:${port}`,
success: true,
});
})();
}
res.json({
status: "success",
message: "SSH connection established",
connectionLogs,
});
if (hostId && userId) {
void logFileManagerActivity(hostId, userId, username, ip, port);
}
});
client.on("error", (err) => {
if (responseSent) return;
responseSent = true;
fileLogger.error("SSH connection failed for file manager", {
operation: "file_connect",
sessionId,
hostId,
ip,
port,
username,
error: err.message,
});
let errorStage: ConnectionStage;
if (
err.message.includes("ENOTFOUND") ||
err.message.includes("getaddrinfo")
) {
errorStage = "dns";
connectionLogs.push(
createConnectionLog(
"error",
errorStage,
`DNS resolution failed: ${err.message}`,
),
);
} else if (
err.message.includes("ECONNREFUSED") ||
err.message.includes("ETIMEDOUT")
) {
errorStage = "tcp";
connectionLogs.push(
createConnectionLog(
"error",
errorStage,
`TCP connection failed: ${err.message}`,
),
);
} else if (
err.message.includes("handshake") ||
err.message.includes("key exchange")
) {
errorStage = "handshake";
connectionLogs.push(
createConnectionLog(
"error",
errorStage,
`SSH handshake failed: ${err.message}`,
),
);
} else if (
err.message.includes("authentication") ||
err.message.includes("Authentication")
) {
errorStage = "auth";
connectionLogs.push(
createConnectionLog(
"error",
errorStage,
`Authentication failed: ${err.message}`,
),
);
} else if (err.message.includes("verification failed")) {
errorStage = "handshake";
connectionLogs.push(
createConnectionLog(
"error",
errorStage,
"SSH host key has changed. For security, please open a Terminal connection to this host first to verify and accept the new key fingerprint.",
),
);
} else {
connectionLogs.push(
createConnectionLog(
"error",
"error",
`SSH connection failed: ${err.message}`,
),
);
}
if (
err.message.includes("Cannot parse privateKey") &&
err.message.includes("no passphrase")
) {
res.json({ status: "passphrase_required", connectionLogs });
} else if (
credentialless &&
(err.message.includes("authentication") ||
err.message.includes("All configured authentication methods failed"))
) {
res.json({
status: "auth_required",
reason: "no_keyboard",
connectionLogs,
});
} else {
res
.status(500)
.json({ status: "error", message: err.message, connectionLogs });
}
});
client.on("close", () => {
fileLogger.info("File manager SSH connection closed", {
operation: "file_ssh_disconnected",
sessionId,
userId,
hostId,
});
if (sshSessions[sessionId]) sshSessions[sessionId].isConnected = false;
cleanupSession(sessionId);
});
const parkForAnswer = (
finish: (responses: string[]) => void,
prompts: Array<{ prompt: string; echo: boolean }>,
promptIndex: number,
isBrowserSignIn = false,
) => {
pendingTOTPSessions[sessionId] = {
client,
finish,
config,
createdAt: Date.now(),
sessionId,
hostId,
ip,
port,
username,
userId,
prompts,
totpPromptIndex: promptIndex,
resolvedPassword: resolvedCredentials.password,
totpAttempts: 0,
...(isBrowserSignIn ? { isBrowserSignIn: true } : {}),
};
};
client.on(
"keyboard-interactive",
(
name: string,
instructions: string,
_instructionsLang: string,
prompts: Array<{ prompt: string; echo: boolean }>,
finish: (responses: string[]) => void,
) => {
const decision = ssh.classifyKeyboardInteractive(
{ name, instructions, prompts },
connectTarget,
);
const autoFinish = () =>
finish(ssh.autoResponses(prompts, resolvedCredentials.password));
if (decision.kind === "auto") {
finish(decision.responses);
return;
}
if (decision.kind === "browser") {
if (responseSent) return;
responseSent = true;
connectionLogs.push(
createConnectionLog(
"info",
"sftp_auth",
`${decision.label} sign-in required`,
{ url: decision.url },
),
);
parkForAnswer(finish, prompts, -1, true);
res.json({
requires_browser_sign_in: true,
sessionId,
label: decision.label,
url: decision.url,
code: decision.code,
connectionLogs,
});
return;
}
const promptIndex = decision.promptIndex;
const promptText = prompts[promptIndex].prompt;
const isPasswordPrompt = /password/i.test(promptText);
if (
decision.kind === "input" &&
!(decision as { isPush?: boolean }).isPush &&
!isPasswordPrompt
) {
autoFinish();
return;
}
if (decision.kind === "input" && isPasswordPrompt && credentialless) {
if (responseSent) return;
responseSent = true;
client.end();
res.json({ status: "auth_required", reason: "no_keyboard" });
return;
}
if (responseSent || pendingTOTPSessions[sessionId]) {
autoFinish();
return;
}
responseSent = true;
parkForAnswer(finish, prompts, promptIndex);
if (decision.kind === "input" && isPasswordPrompt) {
res.json({
requires_totp: true,
sessionId,
prompt: promptText,
isPassword: true,
});
return;
}
connectionLogs.push(
createConnectionLog(
"info",
"sftp_auth",
"TOTP verification required",
{
prompt: promptText,
},
),
);
res.json({
requires_totp: true,
sessionId,
prompt: promptText,
connectionLogs,
});
},
);
const jumpHostCount: number = Array.isArray(resolvedJumpHosts)
? resolvedJumpHosts.length
: 0;
if (jumpHostCount > 0) {
if (hasSocks5Config(connectTarget)) {
connectionLogs.push(
createConnectionLog(
"info",
"proxy",
"Connecting via proxy + jump hosts",
),
);
}
connectionLogs.push(
createConnectionLog(
"info",
"jump",
`Connecting via ${jumpHostCount} jump host(s)`,
),
);
} else if (hasSocks5Config(connectTarget)) {
connectionLogs.push(
createConnectionLog("info", "proxy", "Connecting via proxy", {
proxyHost: resolvedSocks5Host,
proxyPort: resolvedSocks5Port || 1080,
}),
);
}
// A jump host asking for TOTP answers through /connect-totp like the host.
const jumpHostPrompt: PluginSshPromptChannel = {
ask: (request) =>
new Promise((resolve) => {
if (
(request.kind !== "totp" && request.kind !== "input") ||
responseSent ||
pendingTOTPSessions[sessionId]
) {
resolve(null);
return;
}
responseSent = true;
parkForAnswer(
(answers) => resolve(answers[0] ?? null),
[
{
prompt: request.prompt,
echo: request.kind === "input" ? request.echo : false,
},
],
0,
);
res.json({
requires_totp: true,
sessionId,
prompt: request.prompt,
...(request.kind === "input" && !request.isPush
? { isPassword: true }
: {}),
connectionLogs,
});
}),
};
try {
const transport = await ssh.openTransport(connectTarget, config, {
prompt: jumpHostPrompt,
});
if (transport.via === "proxy") {
connectionLogs.push(
createConnectionLog(
"success",
"proxy",
"Proxy connected successfully",
),
);
}
if (transport.jumpClient) {
const jumpClient = transport.jumpClient as SSHClient;
client.on("close", () => jumpClient.end());
}
} catch (error) {
fileLogger.error("File manager transport failed", error, {
operation: "file_transport",
sessionId,
hostId,
});
// Already answered with a jump host prompt: fail the pending verify.
if (responseSent) {
client.emit("error", error);
return;
}
const stage =
isJumpHostChainError(error) || jumpHostCount > 0 ? "jump" : "proxy";
connectionLogs.push(
createConnectionLog("error", stage, getErrorMessage(error)),
);
return res.status(500).json({
error: isJumpHostChainError(error)
? `Failed to connect through jump hosts: ${error.message}`
: getErrorMessage(error),
connectionLogs,
});
}
client.connect(config);
});
async function logFileManagerActivity(
hostId: number,
userId: string,
username: string,
ip: string,
port: number,
): Promise<void> {
try {
const host = await ctx.hosts.get(hostId);
await ctx.asUser(userId, () =>
ctx.hosts.recordActivity(
hostId,
"file_manager",
host?.name || `${username}@${ip}:${port}`,
),
);
} catch (error) {
fileLogger.warn("Failed to log file manager activity", {
operation: "activity_log_error",
userId,
hostId,
error: getErrorMessage(error),
});
}
}
/**
* @openapi
* /plugin-api/file-manager/connect-totp:
* post:
* summary: Verify TOTP and complete connection
* description: Verifies the TOTP code and completes the SSH connection for file manager.
* tags:
* - File Manager
* responses:
* 200:
* description: TOTP verified, SSH connection established.
* 400:
* description: Session ID and TOTP code required.
* 401:
* description: Invalid TOTP code or authentication required.
* 404:
* description: TOTP session expired.
* 408:
* description: TOTP session timeout.
*/
app.post("/connect-totp", async (req, res) => {
const { sessionId, totpCode } = req.body;
const userId = ctx.currentActor();
if (!userId) {
fileLogger.error("TOTP verification rejected: no authenticated user", {
operation: "file_totp_auth",
sessionId,
});
return res.status(401).json({ error: "Authentication required" });
}
if (!sessionId || !totpCode) {
return res
.status(400)
.json({ error: "Session ID and TOTP code required" });
}
const session = pendingTOTPSessions[sessionId];
if (!session) {
fileLogger.warn("TOTP session not found or expired", {
operation: "file_totp_verify",
sessionId,
userId,
});
return res
.status(404)
.json({ error: "TOTP session expired. Please reconnect." });
}
if (Date.now() - session.createdAt > 180000) {
delete pendingTOTPSessions[sessionId];
try {
session.client.end();
} catch {
// expected
}
return res
.status(408)
.json({ error: "TOTP session timeout. Please reconnect." });
}
const responses = (session.prompts || []).map((p, index) => {
if (index === session.totpPromptIndex) return totpCode;
if (/password/i.test(p.prompt) && session.resolvedPassword) {
return session.resolvedPassword;
}
return "";
});
let responseSent = false;
session.client.once("ready", () => {
if (responseSent) return;
responseSent = true;
clearTimeout(responseTimeout);
delete pendingTOTPSessions[sessionId];
setTimeout(() => {
sshSessions[sessionId] = {
client: session.client,
isConnected: true,
lastActive: Date.now(),
activeOperations: 0,
channelOpener: new ChannelOpenSerializer(),
userId,
ip: session.ip,
port: session.port,
hostId: session.hostId,
username: session.username,
};
scheduleSessionCleanup(sessionId);
res.json({
status: "success",
message: "TOTP verified, SSH connection established",
});
if (session.hostId && session.userId) {
void logFileManagerActivity(
session.hostId,
session.userId,
session.username ?? "",
session.ip ?? "",
session.port ?? 22,
);
}
}, 200);
});
session.client.once("error", (err) => {
if (responseSent) return;
responseSent = true;
clearTimeout(responseTimeout);
delete pendingTOTPSessions[sessionId];
fileLogger.error("TOTP verification failed", {
operation: "file_totp_verify",
sessionId,
userId,
error: err.message,
});
res.status(401).json({ status: "error", message: "Invalid TOTP code" });
});
const responseTimeout = setTimeout(() => {
if (!responseSent) {
responseSent = true;
delete pendingTOTPSessions[sessionId];
res.status(408).json({ error: "TOTP verification timeout" });
}
}, 60000);
session.finish(responses);
});
/**
* @openapi
* /plugin-api/file-manager/connect-browser-sign-in:
* post:
* summary: Continue after a browser sign-in
* description: Answers the parked keyboard-interactive round once the user finished signing in in the browser (an SSH gateway's approval, for example).
* tags:
* - File Manager
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required:
* - sessionId
* properties:
* sessionId:
* type: string
* responses:
* 200:
* description: Signed in, SSH connection established.
* 401:
* description: Authentication failed or unauthorized.
* 404:
* description: Sign-in session expired.
* 408:
* description: Sign-in session timeout.
*/
app.post("/connect-browser-sign-in", async (req, res) => {
const { sessionId } = req.body;
const userId = ctx.currentActor();
if (!userId) {
return res.status(401).json({ error: "Authentication required" });
}
if (!sessionId) {
return res.status(400).json({ error: "Session ID required" });
}
const session = pendingTOTPSessions[sessionId];
if (!session) {
return res
.status(404)
.json({ error: "Sign-in session expired. Please reconnect." });
}
if (!session.isBrowserSignIn) {
return res
.status(400)
.json({ error: "Session is not waiting for a browser sign-in" });
}
if (Date.now() - session.createdAt > 300000) {
delete pendingTOTPSessions[sessionId];
try {
session.client.end();
} catch {
// expected
}
return res
.status(408)
.json({ error: "Sign-in session timeout. Please reconnect." });
}
let responseSent = false;
const responseTimeout = setTimeout(() => {
if (!responseSent) {
responseSent = true;
delete pendingTOTPSessions[sessionId];
res.status(408).json({ error: "Sign-in verification timeout" });
}
}, 60000);
session.client.once("ready", () => {
if (responseSent) return;
responseSent = true;
clearTimeout(responseTimeout);
delete pendingTOTPSessions[sessionId];
setTimeout(() => {
sshSessions[sessionId] = {
client: session.client,
isConnected: true,
lastActive: Date.now(),
activeOperations: 0,
channelOpener: new ChannelOpenSerializer(),
userId,
ip: session.ip,
port: session.port,
hostId: session.hostId,
username: session.username,
};
scheduleSessionCleanup(sessionId);
res.json({
status: "success",
message: "Signed in, SSH connection established",
});
if (session.hostId && session.userId) {
void logFileManagerActivity(
session.hostId,
session.userId,
session.username ?? "",
session.ip ?? "",
session.port ?? 22,
);
}
}, 200);
});
session.client.once("error", () => {
if (responseSent) return;
responseSent = true;
clearTimeout(responseTimeout);
delete pendingTOTPSessions[sessionId];
res.status(401).json({ status: "error", message: "Sign-in failed" });
});
session.finish([""]);
});
/**
* @openapi
* /plugin-api/file-manager/disconnect:
* post:
* summary: Disconnect from SSH
* tags:
* - File Manager
* responses:
* 200:
* description: SSH connection disconnected.
*/
app.post("/disconnect", (req, res) => {
const { sessionId } = req.body;
const userId = ctx.currentActor();
const session = sshSessions[sessionId];
if (session && (!userId || !verifySessionOwnership(session, userId))) {
return res.status(403).json({ error: "Session access denied" });
}
cleanupSession(sessionId);
res.json({ status: "success", message: "SSH connection disconnected" });
});
/**
* @openapi
* /plugin-api/file-manager/sudo-password:
* post:
* summary: Set sudo password for session
* tags:
* - File Manager
* responses:
* 200:
* description: Sudo password set successfully.
* 400:
* description: Invalid session.
*/
app.post("/sudo-password", (req, res) => {
const { sessionId, password } = req.body;
const userId = ctx.currentActor();
const session = sshSessions[sessionId];
if (!session || !session.isConnected) {
return res.status(400).json({ error: "Invalid or disconnected session" });
}
if (!userId || !verifySessionOwnership(session, userId)) {
return res.status(403).json({ error: "Session access denied" });
}
session.sudoPassword = password;
session.lastActive = Date.now();
res.json({ status: "success", message: "Sudo password set" });
});
/**
* @openapi
* /plugin-api/file-manager/status:
* get:
* summary: Get SSH connection status
* tags:
* - File Manager
* parameters:
* - in: query
* name: sessionId
* required: true
* schema:
* type: string
* responses:
* 200:
* description: SSH connection status.
*/
app.get("/status", (req, res) => {
const sessionId = req.query.sessionId as string;
const userId = ctx.currentActor();
const session = sshSessions[sessionId];
if (session && (!userId || !verifySessionOwnership(session, userId))) {
return res.status(403).json({ error: "Session access denied" });
}
res.json({ status: "success", connected: !!session?.isConnected });
});
/**
* @openapi
* /plugin-api/file-manager/keepalive:
* post:
* summary: Keep SSH session alive
* tags:
* - File Manager
* responses:
* 200:
* description: Session keepalive successful.
* 400:
* description: Session ID is required or session not found.
*/
app.post("/keepalive", async (req, res) => {
const { sessionId } = req.body;
const userId = ctx.currentActor();
if (!sessionId) {
return res.status(400).json({ error: "Session ID is required" });
}
const session = sshSessions[sessionId];
if (!session || !session.isConnected) {
return res.status(400).json({
error: "SSH session not found or not connected",
connected: false,
});
}
if (!userId || !verifySessionOwnership(session, userId)) {
return res.status(403).json({ error: "Session access denied" });
}
session.lastActive = Date.now();
scheduleSessionCleanup(sessionId);
if (session.sftp && !session.sftpPending) {
try {
await new Promise<void>((resolve, reject) => {
session.sftp!.stat("/", (err) => (err ? reject(err) : resolve()));
});
} catch {
session.sftp = undefined;
}
}
res.json({
status: "success",
connected: true,
message: "Session keepalive successful",
lastActive: session.lastActive,
});
});
registerFileListingRoutes(app, {
ctx,
sshSessions,
activeListRequests,
verifySessionOwnership,
});
registerFileContentRoutes(app, { ctx, sshSessions, verifySessionOwnership });
registerFileOperationRoutes(app, {
ctx,
sshSessions,
verifySessionOwnership,
});
registerFileDownloadRoutes(app, {
ctx,
sshSessions,
scheduleSessionCleanup,
verifySessionOwnership,
});
registerFileActionRoutes(app, {
ctx,
sshSessions,
scheduleSessionCleanup,
verifySessionOwnership,
});
registerBookmarkRoutes(app, ctx, {
table,
pinnedTable,
shortcutsTable,
transferRecentTable,
});
/**
* @openapi
* /plugin-api/file-manager/extractArchive:
* post:
* summary: Extract archive file
* description: Extracts an archive file (.tar, .tar.gz, .tgz, .zip, .tar.bz2, .tbz2, .tar.xz, .txz) to a specified or default location on the remote host.
* tags:
* - File Manager
* responses:
* 200:
* description: Archive extracted successfully.
* 400:
* description: Missing required parameters, SSH connection not established, or unsupported archive format.
* 500:
* description: Failed to extract archive.
*/
app.post("/extractArchive", async (req, res) => {
const { sessionId, archivePath, extractPath } = req.body;
const userId = ctx.currentActor();
if (!sessionId || !archivePath) {
return res.status(400).json({ error: "Missing required parameters" });
}
const session = sshSessions[sessionId];
if (!session || !session.isConnected) {
return res.status(400).json({ error: "SSH session not connected" });
}
if (!userId || !verifySessionOwnership(session, userId)) {
return res.status(403).json({ error: "Session access denied" });
}
session.lastActive = Date.now();
scheduleSessionCleanup(sessionId);
const fileName = archivePath.split("/").pop() || "";
const fileExt = fileName.toLowerCase();
let extractCommand: string;
const targetPath =
extractPath || archivePath.substring(0, archivePath.lastIndexOf("/"));
const escapedArchive = archivePath.replace(/'/g, "'\"'\"'");
const escapedTarget = targetPath.replace(/'/g, "'\"'\"'");
const escapedDecompressed = archivePath
.replace(/\.gz$/, "")
.replace(/'/g, "'\"'\"'");
if (fileExt.endsWith(".tar.gz") || fileExt.endsWith(".tgz")) {
extractCommand = `tar -xzf '${escapedArchive}' -C '${escapedTarget}'`;
} else if (fileExt.endsWith(".tar.bz2") || fileExt.endsWith(".tbz2")) {
extractCommand = `tar -xjf '${escapedArchive}' -C '${escapedTarget}'`;
} else if (fileExt.endsWith(".tar.xz")) {
extractCommand = `tar -xJf '${escapedArchive}' -C '${escapedTarget}'`;
} else if (fileExt.endsWith(".tar")) {
extractCommand = `tar -xf '${escapedArchive}' -C '${escapedTarget}'`;
} else if (fileExt.endsWith(".zip")) {
extractCommand = `unzip -o '${escapedArchive}' -d '${escapedTarget}'`;
} else if (fileExt.endsWith(".gz") && !fileExt.endsWith(".tar.gz")) {
extractCommand = `gunzip -c '${escapedArchive}' > '${escapedDecompressed}'`;
} else if (fileExt.endsWith(".bz2") && !fileExt.endsWith(".tar.bz2")) {
extractCommand = `bunzip2 -k '${escapedArchive}'`;
} else if (fileExt.endsWith(".xz") && !fileExt.endsWith(".tar.xz")) {
extractCommand = `unxz -k '${escapedArchive}'`;
} else if (fileExt.endsWith(".7z")) {
extractCommand = `7z x '${escapedArchive}' -o'${escapedTarget}'`;
} else if (fileExt.endsWith(".rar")) {
extractCommand = `unrar x '${escapedArchive}' '${escapedTarget}/'`;
} else {
return res.status(400).json({ error: "Unsupported archive format" });
}
fileLogger.info("Extracting archive", {
operation: "extract_archive",
sessionId,
archivePath,
extractPath: targetPath,
command: extractCommand,
});
execChannel(session, extractCommand, (err, stream) => {
if (err) {
fileLogger.error("SSH exec error during extract:", err, {
operation: "extract_archive",
sessionId,
archivePath,
});
return res
.status(500)
.json({ error: "Failed to execute extract command" });
}
let errorOutput = "";
stream.on("data", () => {});
stream.stderr.on("data", (data: Buffer) => {
errorOutput += data.toString();
});
stream.on("close", (code: number) => {
if (code !== 0) {
fileLogger.error("Extract command failed", {
operation: "extract_archive",
sessionId,
archivePath,
exitCode: code,
error: errorOutput,
});
let friendlyError = errorOutput || "Failed to extract archive";
if (
errorOutput.includes("command not found") ||
errorOutput.includes("not found")
) {
let missingCmd = "";
let installHint = "";
if (fileExt.endsWith(".zip")) {
missingCmd = "unzip";
installHint =
"apt install unzip / yum install unzip / brew install unzip";
} else if (
fileExt.endsWith(".tar.gz") ||
fileExt.endsWith(".tgz") ||
fileExt.endsWith(".tar.bz2") ||
fileExt.endsWith(".tbz2") ||
fileExt.endsWith(".tar.xz") ||
fileExt.endsWith(".tar")
) {
missingCmd = "tar";
installHint = "Usually pre-installed on Linux/Unix systems";
} else if (fileExt.endsWith(".gz")) {
missingCmd = "gunzip";
installHint =
"apt install gzip / yum install gzip / Usually pre-installed";
} else if (fileExt.endsWith(".bz2")) {
missingCmd = "bunzip2";
installHint =
"apt install bzip2 / yum install bzip2 / brew install bzip2";
} else if (fileExt.endsWith(".xz")) {
missingCmd = "unxz";
installHint =
"apt install xz-utils / yum install xz / brew install xz";
} else if (fileExt.endsWith(".7z")) {
missingCmd = "7z";
installHint =
"apt install p7zip-full / yum install p7zip / brew install p7zip";
} else if (fileExt.endsWith(".rar")) {
missingCmd = "unrar";
installHint =
"apt install unrar / yum install unrar / brew install unrar";
}
if (missingCmd) {
friendlyError = `Command '${missingCmd}' not found on remote server. Please install it first: ${installHint}`;
}
}
return res.status(500).json({ error: friendlyError });
}
fileLogger.success("Archive extracted successfully", {
operation: "extract_archive",
sessionId,
archivePath,
extractPath: targetPath,
});
res.json({
success: true,
message: "Archive extracted successfully",
extractPath: targetPath,
});
});
stream.on("error", (streamErr) => {
fileLogger.error("SSH extractArchive stream error:", streamErr, {
operation: "extract_archive",
sessionId,
archivePath,
});
if (!res.headersSent) {
res
.status(500)
.json({ error: "Stream error while extracting archive" });
}
});
});
});
/**
* @openapi
* /plugin-api/file-manager/compressFiles:
* post:
* summary: Compress files
* description: Compresses files and/or directories on the remote host.
* tags:
* - File Manager
* responses:
* 200:
* description: Files compressed successfully.
* 400:
* description: Missing required parameters or unsupported compression format.
* 500:
* description: Failed to compress files.
*/
app.post("/compressFiles", async (req, res) => {
const { sessionId, paths, archiveName, format } = req.body;
const userId = ctx.currentActor();
if (
!sessionId ||
!paths ||
!Array.isArray(paths) ||
paths.length === 0 ||
!archiveName
) {
return res.status(400).json({ error: "Missing required parameters" });
}
const session = sshSessions[sessionId];
if (!session || !session.isConnected) {
return res.status(400).json({ error: "SSH session not connected" });
}
if (!userId || !verifySessionOwnership(session, userId)) {
return res.status(403).json({ error: "Session access denied" });
}
session.lastActive = Date.now();
scheduleSessionCleanup(sessionId);
const compressionFormat = format || "zip";
let compressCommand: string;
const firstPath = paths[0];
const workingDir =
firstPath.substring(0, firstPath.lastIndexOf("/")) || "/";
const escapeShell = (s: string) => s.replace(/'/g, "'\"'\"'");
const fileNames = paths
.map((p) => {
const name = p.split("/").pop();
return `'./${escapeShell(name || "")}'`;
})
.join(" ");
let archivePath = "";
if (archiveName.includes("/")) {
archivePath = archiveName;
} else {
archivePath = workingDir.endsWith("/")
? `${workingDir}${archiveName}`
: `${workingDir}/${archiveName}`;
}
const escapedDir = escapeShell(workingDir);
const escapedArchive = escapeShell(archivePath);
if (compressionFormat === "zip") {
compressCommand = `cd '${escapedDir}' && zip -r '${escapedArchive}' -- ${fileNames}`;
} else if (compressionFormat === "tar.gz" || compressionFormat === "tgz") {
compressCommand = `cd '${escapedDir}' && tar -czf '${escapedArchive}' -- ${fileNames}`;
} else if (
compressionFormat === "tar.bz2" ||
compressionFormat === "tbz2"
) {
compressCommand = `cd '${escapedDir}' && tar -cjf '${escapedArchive}' -- ${fileNames}`;
} else if (compressionFormat === "tar.xz") {
compressCommand = `cd '${escapedDir}' && tar -cJf '${escapedArchive}' -- ${fileNames}`;
} else if (compressionFormat === "tar") {
compressCommand = `cd '${escapedDir}' && tar -cf '${escapedArchive}' -- ${fileNames}`;
} else if (compressionFormat === "7z") {
compressCommand = `cd '${escapedDir}' && 7z a '${escapedArchive}' -- ${fileNames}`;
} else {
return res.status(400).json({ error: "Unsupported compression format" });
}
fileLogger.info("Compressing files", {
operation: "compress_files",
sessionId,
paths,
archivePath,
format: compressionFormat,
command: compressCommand,
});
execChannel(session, compressCommand, (err, stream) => {
if (err) {
fileLogger.error("SSH exec error during compress:", err, {
operation: "compress_files",
sessionId,
paths,
});
return res
.status(500)
.json({ error: "Failed to execute compress command" });
}
let errorOutput = "";
stream.on("data", () => {});
stream.stderr.on("data", (data: Buffer) => {
errorOutput += data.toString();
});
stream.on("close", (code: number) => {
if (code !== 0) {
fileLogger.error("Compress command failed", {
operation: "compress_files",
sessionId,
paths,
archivePath,
exitCode: code,
error: errorOutput,
});
let friendlyError = errorOutput || "Failed to compress files";
if (
errorOutput.includes("command not found") ||
errorOutput.includes("not found")
) {
const commandMap: Record<string, { cmd: string; install: string }> =
{
zip: {
cmd: "zip",
install:
"apt install zip / yum install zip / brew install zip",
},
"tar.gz": {
cmd: "tar",
install: "Usually pre-installed on Linux/Unix systems",
},
"tar.bz2": {
cmd: "tar",
install: "Usually pre-installed on Linux/Unix systems",
},
"tar.xz": {
cmd: "tar",
install: "Usually pre-installed on Linux/Unix systems",
},
tar: {
cmd: "tar",
install: "Usually pre-installed on Linux/Unix systems",
},
"7z": {
cmd: "7z",
install:
"apt install p7zip-full / yum install p7zip / brew install p7zip",
},
};
const info = commandMap[compressionFormat];
if (info) {
friendlyError = `Command '${info.cmd}' not found on remote server. Please install it first: ${info.install}`;
}
}
return res.status(500).json({ error: friendlyError });
}
fileLogger.success("Files compressed successfully", {
operation: "compress_files",
sessionId,
paths,
archivePath,
format: compressionFormat,
});
res.json({
success: true,
message: "Files compressed successfully",
archivePath,
});
});
stream.on("error", (streamErr) => {
fileLogger.error("SSH compressFiles stream error:", streamErr, {
operation: "compress_files",
sessionId,
paths,
});
if (!res.headersSent) {
res
.status(500)
.json({ error: "Stream error while compressing files" });
}
});
});
});
const hostTransferDeps: HostTransferDeps = {
sshSessions,
getSessionSftp,
execChannel,
verifySessionOwnership,
openDedicatedTransferSession,
closeDedicatedTransferSession,
};
/**
* @openapi
* /plugin-api/file-manager/transferMethodPreview:
* post:
* summary: Preview how a host to host transfer would run
* description: Says whether tar or per-file SFTP would be used, and why, without moving anything.
* tags: [File Manager]
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required: [sourceSessionId, destSessionId, sourcePaths, destPath]
* properties:
* sourceSessionId: { type: string }
* destSessionId: { type: string }
* sourcePaths: { type: array, items: { type: string } }
* destPath: { type: string }
* methodPreference: { type: string, enum: [auto, tar, item_sftp] }
* responses:
* 200: { description: The chosen method and the reason. }
* 400: { description: Missing parameters. }
*/
app.post("/transferMethodPreview", async (req, res) => {
const {
sourceSessionId,
destSessionId,
sourcePaths,
destPath,
methodPreference,
} = req.body;
if (
!sourceSessionId ||
!destSessionId ||
!destPath ||
!sourcePaths ||
!Array.isArray(sourcePaths) ||
sourcePaths.length === 0
) {
return res.status(400).json({ error: "Missing required parameters" });
}
try {
const userId = ctx.currentActor();
const preview = await previewArchiveTransferMethod(hostTransferDeps, {
sourceSessionId,
destSessionId,
sourcePaths,
destPath,
methodPreference:
methodPreference === "tar" || methodPreference === "item_sftp"
? methodPreference
: "auto",
userId: userId ?? "",
});
res.json(preview);
} catch (err) {
fileLogger.error("Failed to preview transfer method", err, {
operation: "host_transfer",
sourceSessionId,
destSessionId,
sourcePaths,
});
res
.status(500)
.json({ error: getErrorMessage(err, "Failed to preview method") });
}
});
/**
* @openapi
* /plugin-api/file-manager/transferToHost:
* post:
* summary: Copy or move files from one host to another
* description: Starts a background transfer between two of the caller's file manager sessions.
* tags: [File Manager]
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required: [sourceSessionId, destSessionId, sourcePaths, destPath]
* properties:
* sourceSessionId: { type: string }
* destSessionId: { type: string }
* sourcePaths: { type: array, items: { type: string } }
* destPath: { type: string }
* methodPreference: { type: string, enum: [auto, tar, item_sftp] }
* move: { type: boolean, description: Delete the source after a successful copy. }
* parallelSegmentCount: { type: integer }
* responses:
* 200: { description: The new transfer id. }
* 400: { description: Missing parameters or a session is not connected. }
* 403: { description: A session belongs to someone else. }
* 500: { description: The transfer could not start. }
*/
app.post("/transferToHost", async (req, res) => {
const {
sourceSessionId,
sourcePaths,
destSessionId,
destPath,
move,
methodPreference,
parallelSegmentCount: parallelSegmentCountRaw,
} = req.body;
const userId = ctx.currentActor();
if (
!sourceSessionId ||
!destSessionId ||
!destPath ||
!sourcePaths ||
!Array.isArray(sourcePaths) ||
sourcePaths.length === 0
) {
return res.status(400).json({ error: "Missing required parameters" });
}
const sourceSession = sshSessions[sourceSessionId];
const destSession = sshSessions[destSessionId];
if (!sourceSession?.isConnected || !destSession?.isConnected) {
return res.status(400).json({ error: "SSH session not connected" });
}
if (
!userId ||
!verifySessionOwnership(sourceSession, userId) ||
!verifySessionOwnership(destSession, userId)
) {
return res.status(403).json({ error: "Session access denied" });
}
sourceSession.lastActive = Date.now();
destSession.lastActive = Date.now();
scheduleSessionCleanup(sourceSessionId);
scheduleSessionCleanup(destSessionId);
try {
const rawParallel = Number(parallelSegmentCountRaw);
const parallelSegmentCount = Number.isFinite(rawParallel)
? Math.max(1, Math.min(8, Math.floor(rawParallel)))
: undefined;
const { transferId } = startHostTransfer(hostTransferDeps, {
sourceSessionId,
sourcePaths,
destSessionId,
destPath,
move: !!move,
userId,
methodPreference:
methodPreference === "tar" || methodPreference === "item_sftp"
? methodPreference
: "auto",
parallelSegmentCount,
});
res.json({ transferId });
} catch (err) {
fileLogger.error("Failed to start host transfer", err, {
operation: "host_transfer",
sourceSessionId,
destSessionId,
sourcePaths,
});
res.status(500).json({ error: "Failed to start transfer" });
}
});
/**
* @openapi
* /plugin-api/file-manager/activeTransfers:
* get:
* summary: List the caller's running host to host transfers
* tags: [File Manager]
* responses:
* 200: { description: The active transfers. }
* 401: { description: Not signed in. }
*/
app.get("/activeTransfers", async (req, res) => {
const userId = ctx.currentActor();
if (!userId)
return res.status(401).json({ error: "Authentication required" });
await probeHungStreamTransfers(hostTransferDeps);
res.json({ transfers: listActiveTransfers(userId) });
});
/**
* @openapi
* /plugin-api/file-manager/transferStatus/{transferId}:
* get:
* summary: Get a transfer's progress
* tags: [File Manager]
* parameters:
* - in: path
* name: transferId
* required: true
* schema: { type: string }
* responses:
* 200: { description: The transfer's status and progress. }
* 401: { description: Not signed in. }
* 404: { description: No such transfer for this user. }
*/
app.get("/transferStatus/:transferId", async (req, res) => {
const userId = ctx.currentActor();
const transferId = req.params.transferId;
if (!userId)
return res.status(401).json({ error: "Authentication required" });
await probeHungStreamTransfers(hostTransferDeps);
const status = getTransferStatus(transferId, userId);
if (!status) return res.status(404).json({ error: "Transfer not found" });
res.json(status);
});
/**
* @openapi
* /plugin-api/file-manager/transferCancel/{transferId}:
* post:
* summary: Cancel a running transfer
* tags: [File Manager]
* parameters:
* - in: path
* name: transferId
* required: true
* schema: { type: string }
* responses:
* 200: { description: The transfer was asked to stop. }
* 401: { description: Not signed in. }
*/
app.post("/transferCancel/:transferId", (req, res) => {
const userId = ctx.currentActor();
const transferId = req.params.transferId;
const cancelled = userId
? requestTransferCancel(transferId, userId)
: false;
if (!cancelled)
return res
.status(404)
.json({ error: "Transfer not found or not running" });
res.json({ ok: true });
});
/**
* @openapi
* /plugin-api/file-manager/transferCleanup/{transferId}:
* post:
* summary: Remove the partial files a cancelled transfer left
* tags: [File Manager]
* parameters:
* - in: path
* name: transferId
* required: true
* schema: { type: string }
* responses:
* 200: { description: What was removed. }
* 401: { description: Not signed in. }
* 404: { description: No such transfer for this user. }
*/
app.post("/transferCleanup/:transferId", async (req, res) => {
const userId = ctx.currentActor();
const transferId = req.params.transferId;
if (!userId)
return res.status(401).json({ error: "Authentication required" });
try {
const result = await cleanupCancelledTransfer(
hostTransferDeps,
transferId,
userId,
);
res.json(result);
} catch (err) {
const message = getErrorMessage(err, "Failed to clean up transfer");
const status = message === "Transfer not found" ? 404 : 400;
res.status(status).json({ error: message });
}
});
/**
* @openapi
* /plugin-api/file-manager/transferRetry/{transferId}:
* post:
* summary: Retry a failed or cancelled transfer
* tags: [File Manager]
* parameters:
* - in: path
* name: transferId
* required: true
* schema: { type: string }
* responses:
* 200: { description: The transfer started again. }
* 401: { description: Not signed in. }
* 404: { description: No such transfer for this user. }
*/
app.post("/transferRetry/:transferId", (req, res) => {
const userId = ctx.currentActor();
const transferId = req.params.transferId;
if (!userId)
return res.status(401).json({ error: "Authentication required" });
const retried = retryHostTransfer(hostTransferDeps, transferId, userId);
if (!retried)
return res
.status(404)
.json({ error: "Transfer not found or not retryable" });
res.json({ ok: true, transferId });
});
ctx.http.router<Router>({ bodyLimit: "1gb" }).use(app);
const service = createFilesService(ctx, {
sshSessions,
verifySessionOwnership,
});
ctx.services.provide("files.sftp", service);
ctx.log.info("File manager mounted at /plugin-api/file-manager");
}
export async function deactivate() {
// Sessions are closed by the disposable registered in activate; the http
// router, db tables and service are all disposed by core.
}