feat: convert file manager into a plugin

This commit is contained in:
LukeGus committed 2026-09-23 16:27:29 -05:00
1 parent b76da9ab52
commit c85c1fdb86
259 files changed
+54155 -16791

No files matched your search

@@ -0,0 +1,483 @@
import type { Express } from "express";
import type { PluginContext } from "@termix/plugin-sdk/backend";
import { execChannel, type SSHSession } from "./session.js";
type FileActionRoutesDeps = {
ctx: PluginContext;
sshSessions: Record<string, SSHSession>;
scheduleSessionCleanup: (sessionId: string) => void;
verifySessionOwnership: (session: SSHSession, userId: string) => boolean;
};
export function registerFileActionRoutes(
app: Express,
{
ctx,
sshSessions,
scheduleSessionCleanup,
verifySessionOwnership,
}: FileActionRoutesDeps,
): void {
/**
* @openapi
* /plugin-api/file-manager/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("/copyItem", async (req, res) => {
const { sessionId, sourcePath, targetDir, hostId } = req.body;
const userId = ctx.currentActor()!;
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(() => {
ctx.log.error(
`Copy command timed out after 60 seconds: ${sourcePath} -> ${targetPath} (${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);
ctx.log.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";
ctx.log.error(
`SSH copyItem command failed with code ${code}: ${sourcePath} -> ${targetPath} (${copyCommand}) ${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) {
ctx.log.info(
`Item copied successfully: ${sourcePath} -> ${targetPath} (sessionId=${sessionId}, hostId=${hostId})`,
);
if (!res.headersSent) {
res.json({
message: "Item copied successfully",
sourcePath,
targetPath,
uniqueName,
toast: {
type: "success",
message: `Successfully copied to: ${uniqueName}`,
},
});
}
} else {
ctx.log.warn(
`Copy completed but without success confirmation: ${sourcePath} -> ${targetPath} (sessionId=${sessionId}, code=${code}, stdout=${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);
ctx.log.error("SSH copyItem stream error:", streamErr);
if (!res.headersSent) {
res
.status(500)
.json({ error: `Stream error: ${streamErr.message}` });
}
});
});
});
});
/**
* @openapi
* /plugin-api/file-manager/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("/executeFile", async (req, res) => {
const { sessionId, filePath } = req.body;
const sshConn = sshSessions[sessionId];
const userId = ctx.currentActor()!;
if (!sshConn || !sshConn.isConnected) {
ctx.log.error(
`SSH connection not found or not connected for executeFile: ${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) {
ctx.log.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) {
ctx.log.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();
ctx.log.info(
`File execution completed: ${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) => {
ctx.log.error("SSH executeFile stream error:", streamErr);
if (!res.headersSent) {
res.status(500).json({ error: "Execution stream error" });
}
});
});
});
});
});
/**
* @openapi
* /plugin-api/file-manager/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("/changePermissions", async (req, res) => {
const { sessionId, path, permissions } = req.body;
const sshConn = sshSessions[sessionId];
const userId = ctx.currentActor()!;
if (!sshConn || !sshConn.isConnected) {
ctx.log.error(
`SSH connection not found or not connected for changePermissions: ${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"`;
ctx.log.info(
`Changing file permissions: ${sessionId} (${path}, permissions=${octalPerms})`,
);
const commandTimeout = setTimeout(() => {
if (!res.headersSent) {
ctx.log.error(
`changePermissions command timeout: ${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);
ctx.log.error(
`SSH changePermissions exec error: ${sessionId} (${path}, permissions=${octalPerms})`,
err,
);
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")) {
ctx.log.info(
`File permissions changed successfully: ${sessionId} (${path}, permissions=${octalPerms})`,
);
if (!res.headersSent) {
res.json({
success: true,
message: "Permissions changed successfully",
});
}
return;
}
if (code !== 0) {
ctx.log.error(
`chmod command failed: ${sessionId} (${path}, permissions=${octalPerms}, exitCode=${code}, error=${errorOutput})`,
);
if (!res.headersSent) {
return res.status(500).json({
error: errorOutput || "Failed to change permissions",
});
}
return;
}
ctx.log.info(
`File permissions changed successfully: ${sessionId} (${path}, permissions=${octalPerms})`,
);
if (!res.headersSent) {
res.json({
success: true,
message: "Permissions changed successfully",
});
}
});
stream.on("error", (streamErr) => {
clearTimeout(commandTimeout);
ctx.log.error(
`SSH changePermissions stream error: ${sessionId} (${path}, permissions=${octalPerms})`,
streamErr,
);
if (!res.headersSent) {
res
.status(500)
.json({ error: "Stream error while changing permissions" });
}
});
});
});
}
@@ -0,0 +1,645 @@
import type { Express, Request, Response } from "express";
import { and, desc, eq } from "drizzle-orm";
import type { PluginContext } from "@termix/plugin-sdk/backend";
/* eslint-disable @typescript-eslint/no-explicit-any */
// The tables come from ctx.db.define, which the SDK hands back untyped, and
// the drizzle handle is the server's own. Typed at this module's edge instead.
type Table = any;
type Drizzle = any;
/* eslint-enable @typescript-eslint/no-explicit-any */
export interface BookmarkTables {
table: Table;
pinnedTable: Table;
shortcutsTable: Table;
transferRecentTable: Table;
}
function isNonEmptyString(value: unknown): value is string {
return typeof value === "string" && value.length > 0;
}
/**
* Recent/pinned/shortcut files and recent transfer destinations, adopted from
* core's host.ts and host-file-manager-bookmark-routes.ts. Kept simple, direct
* Drizzle calls rather than a repository layer, since each is a handful of
* lines and nothing else in the plugin reuses them.
*/
export function registerBookmarkRoutes(
app: Express,
ctx: PluginContext,
tables: BookmarkTables,
): void {
const { table, pinnedTable, shortcutsTable, transferRecentTable } = tables;
const client = () => ctx.db.client<Drizzle>();
/**
* @openapi
* /plugin-api/file-manager/recent:
* get:
* summary: Get recent files
* description: Retrieves a list of recent files for a specific host.
* tags:
* - File Manager
* parameters:
* - in: query
* name: hostId
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: A list of recent files.
* 400:
* description: Invalid userId or hostId.
* 500:
* description: Failed to fetch recent files.
*/
app.get("/recent", async (req: Request, res: Response) => {
const userId = ctx.currentActor();
const hostIdQuery = Array.isArray(req.query.hostId)
? req.query.hostId[0]
: req.query.hostId;
const hostId = hostIdQuery ? parseInt(hostIdQuery as string) : null;
if (!isNonEmptyString(userId)) {
return res.status(400).json({ error: "Invalid userId" });
}
if (!hostId) {
return res.status(400).json({ error: "Host ID is required" });
}
try {
const drizzle = await client();
const rows = await drizzle
.select()
.from(table)
.where(and(eq(table.userId, userId), eq(table.hostId, hostId)))
.orderBy(desc(table.lastOpened))
.limit(20);
res.json(rows);
} catch (err) {
ctx.log.error("Failed to fetch recent files", err as Error);
res.status(500).json({ error: "Failed to fetch recent files" });
}
});
/**
* @openapi
* /plugin-api/file-manager/recent:
* post:
* summary: Add recent file
* description: Adds a file to the list of recent files for a host.
* tags:
* - File Manager
* responses:
* 200:
* description: Recent file added.
* 400:
* description: Invalid data.
* 500:
* description: Failed to add recent file.
*/
app.post("/recent", async (req: Request, res: Response) => {
const userId = ctx.currentActor();
const { hostId, path, name } = req.body;
if (!isNonEmptyString(userId) || !hostId || !path) {
return res.status(400).json({ error: "Invalid data" });
}
try {
const drizzle = await client();
const existing = await drizzle
.select()
.from(table)
.where(
and(
eq(table.userId, userId),
eq(table.hostId, hostId),
eq(table.path, path),
),
)
.limit(1);
const now = new Date().toISOString();
if (existing[0]) {
await drizzle
.update(table)
.set({ name: name ?? existing[0].name, lastOpened: now })
.where(eq(table.id, existing[0].id));
} else {
await drizzle.insert(table).values({
userId,
hostId,
path,
name: name ?? path,
lastOpened: now,
});
}
await ctx.db.persist();
res.json({ message: "Recent file added" });
} catch (err) {
ctx.log.error("Failed to add recent file", err as Error);
res.status(500).json({ error: "Failed to add recent file" });
}
});
/**
* @openapi
* /plugin-api/file-manager/recent:
* delete:
* summary: Remove recent file
* tags:
* - File Manager
* responses:
* 200:
* description: Recent file removed.
* 400:
* description: Invalid data.
* 500:
* description: Failed to remove recent file.
*/
app.delete("/recent", async (req: Request, res: Response) => {
const userId = ctx.currentActor();
const { hostId, path } = req.body;
if (!isNonEmptyString(userId) || !hostId || !path) {
return res.status(400).json({ error: "Invalid data" });
}
try {
const drizzle = await client();
await drizzle
.delete(table)
.where(
and(
eq(table.userId, userId),
eq(table.hostId, hostId),
eq(table.path, path),
),
);
await ctx.db.persist();
res.json({ message: "Recent file removed" });
} catch (err) {
ctx.log.error("Failed to remove recent file", err as Error);
res.status(500).json({ error: "Failed to remove recent file" });
}
});
/**
* @openapi
* /plugin-api/file-manager/pinned:
* get:
* summary: Get pinned files
* tags:
* - File Manager
* parameters:
* - in: query
* name: hostId
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: A list of pinned files.
* 400:
* description: Invalid userId or hostId.
* 500:
* description: Failed to fetch pinned files.
*/
app.get("/pinned", async (req: Request, res: Response) => {
const userId = ctx.currentActor();
const hostIdQuery = Array.isArray(req.query.hostId)
? req.query.hostId[0]
: req.query.hostId;
const hostId = hostIdQuery ? parseInt(hostIdQuery as string) : null;
if (!isNonEmptyString(userId)) {
return res.status(400).json({ error: "Invalid userId" });
}
if (!hostId) {
return res.status(400).json({ error: "Host ID is required" });
}
try {
const drizzle = await client();
const rows = await drizzle
.select()
.from(pinnedTable)
.where(
and(eq(pinnedTable.userId, userId), eq(pinnedTable.hostId, hostId)),
)
.orderBy(desc(pinnedTable.pinnedAt));
res.json(rows);
} catch (err) {
ctx.log.error("Failed to fetch pinned files", err as Error);
res.status(500).json({ error: "Failed to fetch pinned files" });
}
});
/**
* @openapi
* /plugin-api/file-manager/pinned:
* post:
* summary: Add pinned file
* tags:
* - File Manager
* responses:
* 200:
* description: File pinned.
* 400:
* description: Invalid data.
* 409:
* description: File already pinned.
* 500:
* description: Failed to pin file.
*/
app.post("/pinned", async (req: Request, res: Response) => {
const userId = ctx.currentActor();
const { hostId, path, name } = req.body;
if (!isNonEmptyString(userId) || !hostId || !path) {
return res.status(400).json({ error: "Invalid data" });
}
try {
const drizzle = await client();
const existing = await drizzle
.select()
.from(pinnedTable)
.where(
and(
eq(pinnedTable.userId, userId),
eq(pinnedTable.hostId, hostId),
eq(pinnedTable.path, path),
),
)
.limit(1);
if (existing[0]) {
return res.status(409).json({ error: "File already pinned" });
}
await drizzle.insert(pinnedTable).values({
userId,
hostId,
path,
name: name ?? path,
pinnedAt: new Date().toISOString(),
});
await ctx.db.persist();
res.json({ message: "File pinned" });
} catch (err) {
ctx.log.error("Failed to pin file", err as Error);
res.status(500).json({ error: "Failed to pin file" });
}
});
/**
* @openapi
* /plugin-api/file-manager/pinned:
* delete:
* summary: Remove pinned file
* tags:
* - File Manager
* responses:
* 200:
* description: Pinned file removed.
* 400:
* description: Invalid data.
* 500:
* description: Failed to remove pinned file.
*/
app.delete("/pinned", async (req: Request, res: Response) => {
const userId = ctx.currentActor();
const { hostId, path } = req.body;
if (!isNonEmptyString(userId) || !hostId || !path) {
return res.status(400).json({ error: "Invalid data" });
}
try {
const drizzle = await client();
await drizzle
.delete(pinnedTable)
.where(
and(
eq(pinnedTable.userId, userId),
eq(pinnedTable.hostId, hostId),
eq(pinnedTable.path, path),
),
);
await ctx.db.persist();
res.json({ message: "Pinned file removed" });
} catch (err) {
ctx.log.error("Failed to remove pinned file", err as Error);
res.status(500).json({ error: "Failed to remove pinned file" });
}
});
/**
* @openapi
* /plugin-api/file-manager/shortcuts:
* get:
* summary: Get shortcuts
* tags:
* - File Manager
* parameters:
* - in: query
* name: hostId
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: A list of shortcuts.
* 400:
* description: Invalid userId or hostId.
* 500:
* description: Failed to fetch shortcuts.
*/
app.get("/shortcuts", async (req: Request, res: Response) => {
const userId = ctx.currentActor();
const hostIdQuery = Array.isArray(req.query.hostId)
? req.query.hostId[0]
: req.query.hostId;
const hostId = hostIdQuery ? parseInt(hostIdQuery as string) : null;
if (!isNonEmptyString(userId)) {
return res.status(400).json({ error: "Invalid userId" });
}
if (!hostId) {
return res.status(400).json({ error: "Host ID is required" });
}
try {
const drizzle = await client();
const rows = await drizzle
.select()
.from(shortcutsTable)
.where(
and(
eq(shortcutsTable.userId, userId),
eq(shortcutsTable.hostId, hostId),
),
)
.orderBy(desc(shortcutsTable.createdAt));
res.json(rows);
} catch (err) {
ctx.log.error("Failed to fetch shortcuts", err as Error);
res.status(500).json({ error: "Failed to fetch shortcuts" });
}
});
/**
* @openapi
* /plugin-api/file-manager/shortcuts:
* post:
* summary: Add shortcut
* tags:
* - File Manager
* responses:
* 200:
* description: Shortcut added.
* 400:
* description: Invalid data.
* 409:
* description: Shortcut already exists.
* 500:
* description: Failed to add shortcut.
*/
app.post("/shortcuts", async (req: Request, res: Response) => {
const userId = ctx.currentActor();
const { hostId, path, name } = req.body;
if (!isNonEmptyString(userId) || !hostId || !path) {
return res.status(400).json({ error: "Invalid data" });
}
try {
const drizzle = await client();
const existing = await drizzle
.select()
.from(shortcutsTable)
.where(
and(
eq(shortcutsTable.userId, userId),
eq(shortcutsTable.hostId, hostId),
eq(shortcutsTable.path, path),
),
)
.limit(1);
if (existing[0]) {
return res.status(409).json({ error: "Shortcut already exists" });
}
await drizzle.insert(shortcutsTable).values({
userId,
hostId,
path,
name: name ?? path,
createdAt: new Date().toISOString(),
});
await ctx.db.persist();
res.json({ message: "Shortcut added" });
} catch (err) {
ctx.log.error("Failed to add shortcut", err as Error);
res.status(500).json({ error: "Failed to add shortcut" });
}
});
/**
* @openapi
* /plugin-api/file-manager/shortcuts:
* delete:
* summary: Remove shortcut
* tags:
* - File Manager
* responses:
* 200:
* description: Shortcut removed.
* 400:
* description: Invalid data.
* 500:
* description: Failed to remove shortcut.
*/
app.delete("/shortcuts", async (req: Request, res: Response) => {
const userId = ctx.currentActor();
const { hostId, path } = req.body;
if (!isNonEmptyString(userId) || !hostId || !path) {
return res.status(400).json({ error: "Invalid data" });
}
try {
const drizzle = await client();
await drizzle
.delete(shortcutsTable)
.where(
and(
eq(shortcutsTable.userId, userId),
eq(shortcutsTable.hostId, hostId),
eq(shortcutsTable.path, path),
),
);
await ctx.db.persist();
res.json({ message: "Shortcut removed" });
} catch (err) {
ctx.log.error("Failed to remove shortcut", err as Error);
res.status(500).json({ error: "Failed to remove shortcut" });
}
});
/**
* @openapi
* /plugin-api/file-manager/transfer-recent:
* get:
* summary: Get recent transfer destinations
* description: Retrieves recently used host-to-host transfer destinations for a source host.
* tags:
* - File Manager
* parameters:
* - in: query
* name: sourceHostId
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: A list of recent transfer destinations.
* 400:
* description: Invalid userId or sourceHostId.
* 500:
* description: Failed to fetch recent destinations.
*/
app.get("/transfer-recent", async (req: Request, res: Response) => {
const userId = ctx.currentActor();
const sourceHostIdQuery = Array.isArray(req.query.sourceHostId)
? req.query.sourceHostId[0]
: req.query.sourceHostId;
const sourceHostId = sourceHostIdQuery
? parseInt(sourceHostIdQuery as string)
: null;
if (!isNonEmptyString(userId)) {
return res.status(400).json({ error: "Invalid userId" });
}
if (!sourceHostId) {
return res.status(400).json({ error: "Source host ID is required" });
}
try {
const drizzle = await client();
const rows = await drizzle
.select()
.from(transferRecentTable)
.where(
and(
eq(transferRecentTable.userId, userId),
eq(transferRecentTable.sourceHostId, sourceHostId),
),
)
.orderBy(desc(transferRecentTable.lastUsed))
.limit(10);
res.json(rows);
} catch (err) {
ctx.log.error(
"Failed to fetch transfer recent destinations",
err as Error,
);
res.status(500).json({ error: "Failed to fetch recent destinations" });
}
});
/**
* @openapi
* /plugin-api/file-manager/transfer-recent:
* post:
* summary: Save a recent transfer destination
* tags:
* - File Manager
* responses:
* 200:
* description: Recent destination saved.
* 400:
* description: Invalid data.
* 500:
* description: Failed to save recent destination.
*/
app.post("/transfer-recent", async (req: Request, res: Response) => {
const userId = ctx.currentActor();
const { sourceHostId, destHostId, destPath, destPathLabel } = req.body;
if (
!isNonEmptyString(userId) ||
!sourceHostId ||
!destHostId ||
!destPath
) {
return res.status(400).json({ error: "Invalid data" });
}
try {
const drizzle = await client();
const existing = await drizzle
.select()
.from(transferRecentTable)
.where(
and(
eq(transferRecentTable.userId, userId),
eq(transferRecentTable.sourceHostId, sourceHostId),
eq(transferRecentTable.destHostId, destHostId),
eq(transferRecentTable.destPath, destPath),
),
)
.limit(1);
const now = new Date().toISOString();
if (existing[0]) {
await drizzle
.update(transferRecentTable)
.set({
destPathLabel: destPathLabel ?? existing[0].destPathLabel,
lastUsed: now,
})
.where(eq(transferRecentTable.id, existing[0].id));
} else {
await drizzle.insert(transferRecentTable).values({
userId,
sourceHostId,
destHostId,
destPath,
destPathLabel: destPathLabel ?? destPath,
lastUsed: now,
});
}
// Keep only the 10 most recent destinations per source host.
const all = await drizzle
.select()
.from(transferRecentTable)
.where(
and(
eq(transferRecentTable.userId, userId),
eq(transferRecentTable.sourceHostId, sourceHostId),
),
)
.orderBy(desc(transferRecentTable.lastUsed));
const stale = all.slice(10);
for (const row of stale) {
await drizzle
.delete(transferRecentTable)
.where(eq(transferRecentTable.id, row.id));
}
await ctx.db.persist();
res.json({ message: "Recent destination saved" });
} catch (err) {
ctx.log.error("Failed to save transfer recent destination", err as Error);
res.status(500).json({ error: "Failed to save recent destination" });
}
});
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,72 @@
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);
}
@@ -0,0 +1,316 @@
import type { Express } from "express";
import type { PluginContext } from "@termix/plugin-sdk/backend";
import { getMimeType } from "./utils.js";
import { execChannel, getSessionSftp, type SSHSession } from "./session.js";
type FileDownloadRoutesDeps = {
ctx: PluginContext;
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,
{
ctx,
sshSessions,
scheduleSessionCleanup,
verifySessionOwnership,
}: FileDownloadRoutesDeps,
): void {
/**
* @openapi
* /plugin-api/file-manager/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("/downloadFile", async (req, res) => {
const { sessionId, path: filePath, hostId } = req.body;
const userId = ctx.currentActor()!;
const downloadStartTime = Date.now();
if (!sessionId || !filePath) {
ctx.log.warn(
`Missing download parameters: ${sessionId} (hasFilePath=${!!filePath})`,
);
return res.status(400).json({ error: "Missing download parameters" });
}
ctx.log.info(`File download started: ${sessionId} (${filePath})`);
const sshConn = sshSessions[sessionId];
if (!sshConn || !sshConn.isConnected) {
ctx.log.warn(
`SSH session not found or not connected for 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) {
ctx.log.info(
`Downloading file via legacy exec/cat (SCP legacy mode): ${sessionId} (${filePath})`,
);
try {
const data = await downloadViaExec(sshConn, filePath);
const fileName = filePath.split("/").pop() || "download";
ctx.log.info(
`File download completed (legacy mode): ${sessionId} (${filePath}, hostId=${hostId}, bytes=${data.length}, duration=${Date.now() - downloadStartTime}ms)`,
);
return res.json({
content: data.toString("base64"),
fileName,
size: data.length,
mimeType: getMimeType(fileName),
path: filePath,
});
} catch (err) {
ctx.log.error("Legacy exec/cat download failed:", err as Error);
return res.status(500).json({
error: `Failed to download file: ${(err as Error).message}`,
});
}
}
ctx.log.info(
`Opening SFTP channel for downloadFile: ${sessionId} (${filePath})`,
);
getSessionSftp(sshConn)
.then((sftp) => {
sftp.stat(filePath, (statErr, stats) => {
if (statErr) {
ctx.log.error("File stat failed for download:", statErr);
return res
.status(500)
.json({ error: `Cannot access file: ${statErr.message}` });
}
if (!stats.isFile()) {
ctx.log.warn(
`Attempted to download non-file: ${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) {
ctx.log.warn(
`File too large for 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) {
ctx.log.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";
ctx.log.info(
`File download completed: ${sessionId} (${filePath}, hostId=${hostId}, bytes=${stats.size}, duration=${Date.now() - downloadStartTime}ms)`,
);
res.json({
content: base64Content,
fileName: fileName,
size: stats.size,
mimeType: getMimeType(fileName),
path: filePath,
});
});
});
})
.catch((err) => {
ctx.log.error("SFTP connection failed for download:", err);
return res.status(500).json({ error: "SFTP connection failed" });
});
});
/**
* @openapi
* /plugin-api/file-manager/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("/downloadFileStream", async (req, res) => {
const { sessionId, path: filePath } = req.body;
const userId = ctx.currentActor()!;
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) {
ctx.log.info(
`Streaming file via legacy exec/cat (SCP legacy mode): ${sessionId} (${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}` });
}
}
});
}
@@ -0,0 +1,6 @@
export function getErrorMessage(
error: unknown,
fallback = "Unknown error",
): string {
return error instanceof Error ? error.message : fallback;
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,465 @@
import { getErrorMessage } from "./error-message.js";
import type { Express } from "express";
import type { PluginContext } from "@termix/plugin-sdk/backend";
import { execChannel, getSessionSftp, type SSHSession } from "./session.js";
import {
formatMtime,
isExecutableFile,
modeToPermissions,
parseLsDateToTimestamp,
} from "./utils.js";
type FileListingRoutesDeps = {
ctx: PluginContext;
sshSessions: Record<string, SSHSession>;
activeListRequests: Record<string, boolean>;
verifySessionOwnership: (session: SSHSession, userId: string) => boolean;
};
export function registerFileListingRoutes(
app: Express,
{
ctx,
sshSessions,
activeListRequests,
verifySessionOwnership,
}: FileListingRoutesDeps,
): void {
/**
* @openapi
* /plugin-api/file-manager/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("/listFiles", (req, res) => {
const sessionId = req.query.sessionId as string;
const sshConn = sshSessions[sessionId];
const sshPath = (req.query.path as string) || "/";
const userId = ctx.currentActor()!;
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 {
ctx.log.info(
`Opening SFTP channel for listFiles: ${sessionId} (${sshPath})`,
);
getSessionSftp(sshConn)
.then((sftp) => {
sftp.readdir(sshPath, (readdirErr, list) => {
if (readdirErr) {
ctx.log.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) => {
ctx.log.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);
ctx.log.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--;
ctx.log.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) {
ctx.log.info(
`Permission denied for listFiles, retrying with sudo: ${sshPath}`,
);
tryWithSudo();
return;
}
sshConn.activeOperations--;
ctx.log.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--;
ctx.log.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);
ctx.log.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--;
ctx.log.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()) {
ctx.log.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);
ctx.log.error(`Sudo listFiles exec failed: ${errMsg}`);
if (!res.headersSent) {
return res.status(500).json({ error: errMsg });
}
}
};
trySFTP();
});
}
@@ -0,0 +1,39 @@
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"`,
};
}
@@ -0,0 +1,864 @@
import type { Express, Request, Response } from "express";
import type { PluginContext } from "@termix/plugin-sdk/backend";
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 { PermissionManager } from "../../../../src/backend/utils/permission-manager.js";
type FileOperationRoutesDeps = {
ctx: PluginContext;
sshSessions: Record<string, SSHSession>;
verifySessionOwnership: (session: SSHSession, userId: string) => boolean;
};
const TRASH_RETENTION_DAYS_KEY = "trashRetentionDays";
export function registerFileOperationRoutes(
app: Express,
{ ctx, sshSessions, verifySessionOwnership }: FileOperationRoutesDeps,
): void {
// NOTE: trash retention is an install-wide admin setting, not a per-host
// access check, so it stays on PermissionManager.isAdmin rather than
// ctx.hosts.checkAccess (see rule 5 scope: host resolution/access only).
const permissionManager = PermissionManager.getInstance();
const getTrashRetentionDays = async (): Promise<number> => {
try {
const value = Number(await ctx.settings.get(TRASH_RETENTION_DAYS_KEY));
return Number.isInteger(value) && value >= 1 && value <= 3650 ? value : 7;
} catch {
return 7;
}
};
async function ownedSession(
req: Request,
res: Response,
): Promise<SSHSession | null> {
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, ctx.currentActor()!)) {
res.status(403).json({ error: "Session access denied" });
return null;
}
session.lastActive = Date.now();
return session;
}
app.get("/trash", async (req, res) => {
const session = await ownedSession(req, res);
if (!session) return;
try {
const retentionDays = await getTrashRetentionDays();
res.json({
items: await listTrash(await getSessionSftp(session), retentionDays),
retentionDays,
canManageRetention: await permissionManager.isAdmin(
ctx.currentActor()!,
),
});
} catch (error) {
ctx.log.error("Failed to list trash", error as Error);
res.status(500).json({ error: (error as Error).message });
}
});
app.post("/trash/:id/restore", async (req, res) => {
const session = await ownedSession(req, 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("/trash/:id", async (req, res) => {
const session = await ownedSession(req, 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("/trash", async (req, res) => {
const session = await ownedSession(req, 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("/trash-retention", async (req, res) => {
const userId = ctx.currentActor()!;
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 ctx.settings.set(TRASH_RETENTION_DAYS_KEY, String(retentionDays));
return res.json({ retentionDays });
});
/**
* @openapi
* /plugin-api/file-manager/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("/createFile", async (req, res) => {
const { sessionId, path: filePath, fileName } = req.body;
const sshConn = sshSessions[sessionId];
const userId = ctx.currentActor()!;
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) {
ctx.log.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")) {
ctx.log.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) {
ctx.log.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) => {
ctx.log.error("SSH createFile stream error:", streamErr);
if (!res.headersSent) {
res.status(500).json({ error: `Stream error: ${streamErr.message}` });
}
});
});
});
/**
* @openapi
* /plugin-api/file-manager/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("/createFolder", async (req, res) => {
const { sessionId, path: folderPath, folderName } = req.body;
const sshConn = sshSessions[sessionId];
const userId = ctx.currentActor()!;
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;
ctx.log.info(`Creating directory: ${sessionId} (${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) {
ctx.log.error("SFTP createFolder failed", error as Error);
const code = (error as { code?: string | number }).code;
res.status(code === 3 || code === "EACCES" ? 403 : 500).json({
error: (error as Error).message,
});
}
});
/**
* @openapi
* /plugin-api/file-manager/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("/deleteItem", async (req, res) => {
const { sessionId, path: itemPath, isDirectory, permanent } = req.body;
const sshConn = sshSessions[sessionId];
const userId = ctx.currentActor()!;
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" });
}
ctx.log.info(
`Deleting item: ${sessionId} (${itemPath}, type=${isDirectory ? "directory" : "file"})`,
);
sshConn.lastActive = Date.now();
if (!permanent) {
try {
const sftp = await getSessionSftp(sshConn);
const retentionDays = await getTrashRetentionDays();
await listTrash(sftp, retentionDays);
const item = await moveToTrash(sftp, itemPath);
ctx.log.info(
`Item moved to trash: ${sessionId} (${itemPath}, trashId=${item.id})`,
);
return res.json({
message: "Item moved to trash",
path: itemPath,
trashItem: item,
});
} catch (error) {
ctx.log.error(
`Failed to move item to trash: ${sessionId} (${itemPath})`,
error as Error,
);
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) {
ctx.log.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;
}
ctx.log.error(`Permission denied deleting: ${itemPath}`);
res.status(403).json({
error: `Permission denied: Cannot delete ${itemPath}.`,
needsSudo: true,
});
resolve();
return;
}
if (outputData.includes("SUCCESS")) {
ctx.log.info(
`Item deleted successfully: ${sessionId} (${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)`;
ctx.log.error(
`Delete failed for ${itemPath}: ${detail} (sessionId=${sessionId})`,
);
res.status(500).json({
error: `Delete failed: ${detail}`,
});
}
resolve();
});
stream.on("error", (streamErr) => {
ctx.log.error("SSH deleteItem stream error:", streamErr);
res
.status(500)
.json({ error: `Stream error: ${streamErr.message}` });
resolve();
});
});
});
};
await executeDelete(false);
});
/**
* @openapi
* /plugin-api/file-manager/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("/renameItem", async (req, res) => {
const { sessionId, oldPath, newName } = req.body;
const sshConn = sshSessions[sessionId];
const userId = ctx.currentActor()!;
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;
ctx.log.info(`Renaming item: ${sessionId} (${oldPath} -> ${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) {
ctx.log.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")) {
ctx.log.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")) {
ctx.log.info(
`Item renamed successfully: ${sessionId} (${oldPath} -> ${newPath})`,
);
if (!res.headersSent) {
res.json({
message: "Item renamed successfully",
oldPath,
newPath,
toast: {
type: "success",
message: `Item renamed: ${oldPath} -> ${newPath}`,
},
});
}
return;
}
if (code !== 0) {
ctx.log.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;
}
ctx.log.info(
`Item renamed successfully: ${sessionId} (${oldPath} -> ${newPath})`,
);
if (!res.headersSent) {
res.json({
message: "Item renamed successfully",
oldPath,
newPath,
toast: {
type: "success",
message: `Item renamed: ${oldPath} -> ${newPath}`,
},
});
}
});
stream.on("error", (streamErr) => {
ctx.log.error("SSH renameItem stream error:", streamErr);
if (!res.headersSent) {
res.status(500).json({ error: `Stream error: ${streamErr.message}` });
}
});
});
});
/**
* @openapi
* /plugin-api/file-manager/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("/moveItem", async (req, res) => {
const { sessionId, oldPath, newPath } = req.body;
const sshConn = sshSessions[sessionId];
const userId = ctx.currentActor()!;
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);
ctx.log.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")) {
ctx.log.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) {
ctx.log.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);
ctx.log.error("SSH moveItem stream error:", streamErr);
if (!res.headersSent) {
res.status(500).json({ error: `Stream error: ${streamErr.message}` });
}
});
});
});
}
@@ -0,0 +1,13 @@
import type { PluginContext } from "@termix/plugin-sdk/backend";
let current: PluginContext | null = null;
/** Set in activate, cleared on deactivate. */
export function setPluginCtx(ctx: PluginContext | null): void {
current = ctx;
}
export function pluginCtx(): PluginContext {
if (!current) throw new Error("The plugin is not active");
return current;
}
+143
View File
@@ -0,0 +1,143 @@
import type { PluginContext } from "@termix/plugin-sdk/backend";
import {
promisifySftpMkdir,
promisifySftpReaddir,
promisifySftpStat,
promisifySftpUnlink,
promisifySftpRmdir,
} from "./sftp-promisify.js";
const DEFAULT_DIR_MODE = 0o755;
import type { SSHSession } from "./session.js";
/** What other plugins get from ctx.services.get("files.sftp", {userId}). */
export interface FilesService {
list: (
hostId: number,
path: string,
) => Promise<
Array<{ name: string; type: "file" | "directory" | "link"; size?: number }>
>;
read: (hostId: number, path: string) => Promise<string>;
write: (hostId: number, path: string, content: string) => Promise<void>;
stat: (
hostId: number,
path: string,
) => Promise<{ size: number; isDirectory: boolean; mtime: number }>;
delete: (hostId: number, path: string) => Promise<void>;
mkdir: (hostId: number, path: string) => Promise<void>;
}
/**
* A short-lived SFTP connection for a service call, outside the browse
* session map: a caller reaches this from any request the acting user makes,
* not necessarily one that has an open file-manager tab.
*/
async function withServiceSftp<T>(
ctx: PluginContext,
hostId: number,
fn: (sftp: import("ssh2").SFTPWrapper) => Promise<T>,
): Promise<T> {
return ctx.ssh.withConnection<T, import("ssh2").Client>(
hostId,
{ pool: "file-manager-service", purpose: "file-manager" },
(client) =>
new Promise<T>((resolve, reject) => {
client.sftp(async (err, sftp) => {
if (err) return reject(err);
try {
resolve(await fn(sftp));
} catch (error) {
reject(error);
}
});
}),
);
}
export function createFilesService(
ctx: PluginContext,
deps: {
sshSessions: Record<string, SSHSession>;
verifySessionOwnership: (session: SSHSession, userId: string) => boolean;
},
): FilesService {
void deps;
return {
async list(hostId, path) {
return withServiceSftp(ctx, hostId, async (sftp) => {
const entries = await promisifySftpReaddir(sftp, path);
return entries
.filter((e) => e.filename !== "." && e.filename !== "..")
.map((e) => ({
name: e.filename,
type: e.attrs.isDirectory()
? ("directory" as const)
: e.attrs.isSymbolicLink()
? ("link" as const)
: ("file" as const),
size: e.attrs.isDirectory() ? undefined : e.attrs.size,
}));
});
},
async read(hostId, path) {
return withServiceSftp(
ctx,
hostId,
(sftp) =>
new Promise<string>((resolve, reject) => {
const chunks: Buffer[] = [];
const stream = sftp.createReadStream(path);
stream.on("data", (chunk: Buffer) => chunks.push(chunk));
stream.on("end", () =>
resolve(Buffer.concat(chunks).toString("utf8")),
);
stream.on("error", reject);
}),
);
},
async write(hostId, path, content) {
await withServiceSftp(
ctx,
hostId,
(sftp) =>
new Promise<void>((resolve, reject) => {
const stream = sftp.createWriteStream(path, { flags: "w" });
stream.on("close", () => resolve());
stream.on("error", reject);
stream.end(content, "utf8");
}),
);
},
async stat(hostId, path) {
return withServiceSftp(ctx, hostId, async (sftp) => {
const stats = await promisifySftpStat(sftp, path);
return {
size: stats.size,
isDirectory: stats.isDirectory(),
mtime: stats.mtime,
};
});
},
async delete(hostId, path) {
await withServiceSftp(ctx, hostId, async (sftp) => {
const stats = await promisifySftpStat(sftp, path);
if (stats.isDirectory()) {
await promisifySftpRmdir(sftp, path);
} else {
await promisifySftpUnlink(sftp, path);
}
});
},
async mkdir(hostId, path) {
await withServiceSftp(ctx, hostId, (sftp) =>
promisifySftpMkdir(sftp, path, DEFAULT_DIR_MODE),
);
},
};
}
+282
View File
@@ -0,0 +1,282 @@
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),
);
}
@@ -0,0 +1,120 @@
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);
});
});
}
+79
View File
@@ -0,0 +1,79 @@
import type { Client, ConnectConfig } from "ssh2";
import type {
PluginSsh,
PluginSshAuthOutcome,
PluginSshConnection,
PluginSshConnectOptions,
PluginSshHost,
} from "@termix/plugin-sdk/backend";
let current: PluginSsh | null = null;
/** Set in activate, cleared on deactivate. */
export function setPluginSsh(ssh: PluginSsh | null): void {
current = ssh;
}
export function pluginSsh(): PluginSsh {
if (!current) throw new Error("The plugin is not active");
return current;
}
/** withConnection with ssh2's Client type filled in. */
export function withSshConnection<T>(
host: number | PluginSshHost,
options: PluginSshConnectOptions & { pool: string },
fn: (client: Client) => Promise<T>,
): Promise<T> {
return pluginSsh().withConnection<T, Client>(host, options, fn);
}
/** connect with ssh2's Client type filled in. */
export function connectSsh(
host: number | PluginSshHost,
options?: PluginSshConnectOptions,
): Promise<PluginSshConnection<Client>> {
return pluginSsh().connect<Client>(host, options);
}
/**
* Lower-level primitives for the interactive /connect route, which drives its
* own ssh2 Client through the TOTP/Warpgate parking flow rather than letting
* ctx.ssh.connect finish the handshake itself.
*/
export function prepareSsh(
host: PluginSshHost,
options: {
purpose?: "file-manager" | "file-transfer";
client: Client;
serverHostId?: number;
log?: (level: "info" | "warning" | "error", message: string) => void;
},
): Promise<{ config: ConnectConfig; outcome: PluginSshAuthOutcome }> {
return pluginSsh().prepare(host, options) as Promise<{
config: ConnectConfig;
outcome: PluginSshAuthOutcome;
}>;
}
export function openSshTransport(
host: PluginSshHost,
config: ConnectConfig,
): Promise<{ jumpClient: Client | null; via: string }> {
return pluginSsh().openTransport(
host,
config as unknown as Record<string, unknown>,
) as Promise<{
jumpClient: Client | null;
via: string;
}>;
}
export const classifyKeyboardInteractive: PluginSsh["classifyKeyboardInteractive"] =
(round, host) => pluginSsh().classifyKeyboardInteractive(round, host);
export const autoResponses: PluginSsh["autoResponses"] = (prompts, password) =>
pluginSsh().autoResponses(prompts, password);
export const requiresSecret: PluginSsh["requiresSecret"] = (authType) =>
pluginSsh().requiresSecret(authType);
+105
View File
@@ -0,0 +1,105 @@
import {
adoptLegacyTable,
defineTable,
id,
refHost,
refUser,
text,
timestamp,
} from "@termix/plugin-sdk/db";
/**
* The paths and hosts a user recently opened in the file manager.
*
* Adopted from core's file_manager_recent, so the column and index names are
* the legacy ones: the rename carries the existing rows and index across.
*/
export const recent = adoptLegacyTable(
"file_manager_recent",
defineTable(
"recent",
{
id: id(),
userId: refUser(),
hostId: refHost(),
name: text().notNull(),
path: text().notNull(),
lastOpened: timestamp().notNull().defaultNow(),
},
{
indexes: [
{ name: "idx_file_manager_recent_user", columns: ["userId", "hostId"] },
],
},
),
);
/** Paths a user pinned for quick access on a host. */
export const pinned = adoptLegacyTable(
"file_manager_pinned",
defineTable(
"pinned",
{
id: id(),
userId: refUser(),
hostId: refHost(),
name: text().notNull(),
path: text().notNull(),
pinnedAt: timestamp().notNull().defaultNow(),
},
{
indexes: [
{
name: "idx_file_manager_pinned_user",
columns: ["userId", "hostId"],
},
],
},
),
);
/** Named folder shortcuts a user saved on a host. */
export const shortcuts = adoptLegacyTable(
"file_manager_shortcuts",
defineTable(
"shortcuts",
{
id: id(),
userId: refUser(),
hostId: refHost(),
name: text().notNull(),
path: text().notNull(),
createdAt: timestamp().notNull().defaultNow(),
},
{
indexes: [
{
name: "idx_file_manager_shortcuts_user",
columns: ["userId", "hostId"],
},
],
},
),
);
/** Recently used destinations for host-to-host transfers. */
export const transferRecent = adoptLegacyTable(
"transfer_recent",
defineTable(
"transfer_recent",
{
id: id(),
userId: refUser(),
sourceHostId: refHost(),
destHostId: refHost(),
destPath: text().notNull(),
destPathLabel: text().notNull(),
lastUsed: timestamp().notNull().defaultNow(),
},
{
indexes: [{ name: "idx_transfer_recent_user", columns: ["userId"] }],
},
),
);
export const tables = [recent, pinned, shortcuts, transferRecent];
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,52 @@
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)
);
}
@@ -0,0 +1,59 @@
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")
);
}
@@ -0,0 +1,54 @@
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 };
}
@@ -0,0 +1,151 @@
export type TransferPlatform = "windows" | "unix";
/** OpenSSH SFTP on Windows commonly uses `/C:/...` or `C:/...` style paths. */
export function isWindowsSftpPath(path: string): boolean {
const normalized = path.replace(/\\/g, "/");
return /^[A-Za-z]:\//.test(normalized) || /^\/[A-Za-z]:\//.test(normalized);
}
export function inferPlatformFromPath(path: string): TransferPlatform | null {
if (isWindowsSftpPath(path)) return "windows";
if (path.startsWith("/") && !/^\/[A-Za-z]:/.test(path.replace(/\\/g, "/"))) {
return "unix";
}
return null;
}
export function normalizeSftpPath(path: string): string {
const normalized = path.replace(/\\/g, "/").replace(/\/+$/, "");
if (!normalized) return path.startsWith("/") ? "/" : ".";
return normalized.replace(/([^:])\/+/g, "$1/");
}
export function basename(path: string): string {
const normalized = path.replace(/\\/g, "/").replace(/\/+$/, "");
const idx = normalized.lastIndexOf("/");
if (idx < 0) return normalized;
return normalized.substring(idx + 1) || normalized;
}
export function dirname(path: string): string {
const normalized = path.replace(/\\/g, "/").replace(/\/+$/, "");
if (!normalized) return normalized;
const idx = normalized.lastIndexOf("/");
if (idx < 0) return normalized;
const parent = normalized.substring(0, idx);
if (!parent) return "/";
if (/^\/[A-Za-z]:$/.test(parent) || /^[A-Za-z]:$/.test(parent)) {
return parent;
}
return parent || "/";
}
export function joinPath(base: string, ...parts: string[]): string {
let result = base.replace(/\\/g, "/");
for (const part of parts) {
if (!part) continue;
const segment = part.replace(/\\/g, "/").replace(/^\/+/, "");
if (!segment) continue;
if (result.endsWith("/")) {
result += segment;
} else {
result = `${result}/${segment}`;
}
}
return normalizeSftpPath(result);
}
export interface PathSegments {
/** Drive root prefix, e.g. `/C:` or `C:`; empty for Unix absolute paths. */
root: string;
/** Path segments below the root (may be empty). */
segments: string[];
}
export function splitPathSegments(path: string): PathSegments {
const normalized = normalizeSftpPath(path);
const posixDrive = normalized.match(/^(\/[A-Za-z]:)(?:\/(.*))?$/);
if (posixDrive) {
return {
root: posixDrive[1],
segments: (posixDrive[2] ?? "").split("/").filter(Boolean),
};
}
const drive = normalized.match(/^([A-Za-z]:)(?:\/(.*))?$/);
if (drive) {
return {
root: drive[1],
segments: (drive[2] ?? "").split("/").filter(Boolean),
};
}
if (normalized.startsWith("/")) {
return {
root: "/",
segments: normalized.split("/").filter(Boolean),
};
}
return {
root: "",
segments: normalized.split("/").filter(Boolean),
};
}
export function buildPathFromSegments(
root: string,
segments: string[],
count: number,
): string {
const slice = segments.slice(0, count);
if (/^\/[A-Za-z]:$/.test(root)) {
return slice.length ? `${root}/${slice.join("/")}` : root;
}
if (/^[A-Za-z]:$/.test(root)) {
return slice.length ? `${root}/${slice.join("/")}` : root;
}
if (root === "/") {
return slice.length ? `/${slice.join("/")}` : "/";
}
if (root === "") {
return slice.join("/");
}
return slice.length ? `${root}/${slice.join("/")}` : root;
}
export function pathsOverlap(source: string, dest: string): boolean {
const norm = (p: string) => {
const n = normalizeSftpPath(p).toLowerCase();
return n || (isWindowsSftpPath(p) ? p : "/");
};
const s = norm(source);
const d = norm(dest);
const sep = "/";
return s === d || s.startsWith(`${d}${sep}`) || d.startsWith(`${s}${sep}`);
}
export function getWorkingDir(paths: string[]): string {
if (paths.length === 0) return "/";
const parents = paths.map((p) => dirname(p));
const first = parents[0];
if (parents.every((p) => p === first)) {
return first;
}
return first;
}
/** Convert an SFTP path to a local filesystem path when Termix runs on the dest host. */
export function sftpPathToLocalPath(sftpPath: string): string {
const normalized = sftpPath.replace(/\\/g, "/");
if (/^\/[A-Za-z]:\//.test(normalized)) {
return normalized.slice(1).replace(/\//g, "\\");
}
if (/^[A-Za-z]:\//.test(normalized)) {
return normalized.replace(/\//g, "\\");
}
return sftpPath;
}
@@ -0,0 +1,205 @@
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";
}
@@ -0,0 +1,89 @@
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(() => {});
}
}
@@ -0,0 +1,40 @@
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;
}
@@ -0,0 +1,74 @@
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);
}
}
@@ -0,0 +1,157 @@
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,
};
}
@@ -0,0 +1,388 @@
// TODO(file-manager): move this to ctx.kv, see FINISH-LIST.md
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();
}
@@ -0,0 +1,234 @@
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;
}
+164
View File
@@ -0,0 +1,164 @@
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;
}