Fix SSH encryption and add file download functionality
- Fix SSH authentication by ensuring all database operations use EncryptedDBOperations for automatic encryption/decryption - Resolve SSH connection failures caused by encrypted password data being passed to authentication - Add comprehensive file download functionality for SSH file manager (Issue #228) - Update database migration to add require_password column for SSH sessions - Enhance debugging and logging for SSH connection troubleshooting 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -250,6 +250,12 @@ const migrateSchema = () => {
|
|||||||
"INTEGER REFERENCES ssh_credentials(id)",
|
"INTEGER REFERENCES ssh_credentials(id)",
|
||||||
);
|
);
|
||||||
|
|
||||||
|
addColumnIfNotExists(
|
||||||
|
"ssh_data",
|
||||||
|
"require_password",
|
||||||
|
"INTEGER NOT NULL DEFAULT 1",
|
||||||
|
);
|
||||||
|
|
||||||
// SSH credentials table migrations for encryption support
|
// SSH credentials table migrations for encryption support
|
||||||
addColumnIfNotExists("ssh_credentials", "private_key", "TEXT");
|
addColumnIfNotExists("ssh_credentials", "private_key", "TEXT");
|
||||||
addColumnIfNotExists("ssh_credentials", "public_key", "TEXT");
|
addColumnIfNotExists("ssh_credentials", "public_key", "TEXT");
|
||||||
|
|||||||
@@ -210,9 +210,9 @@ router.post(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await db.insert(sshData).values(sshDataObj).returning();
|
const result = await EncryptedDBOperations.insert(sshData, 'ssh_data', sshDataObj);
|
||||||
|
|
||||||
if (result.length === 0) {
|
if (!result) {
|
||||||
sshLogger.warn("No host returned after creation", {
|
sshLogger.warn("No host returned after creation", {
|
||||||
operation: "host_create",
|
operation: "host_create",
|
||||||
userId,
|
userId,
|
||||||
@@ -223,7 +223,7 @@ router.post(
|
|||||||
return res.status(500).json({ error: "Failed to create host" });
|
return res.status(500).json({ error: "Failed to create host" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const createdHost = result[0];
|
const createdHost = result;
|
||||||
const baseHost = {
|
const baseHost = {
|
||||||
...createdHost,
|
...createdHost,
|
||||||
tags:
|
tags:
|
||||||
@@ -401,15 +401,17 @@ router.put(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await db
|
await EncryptedDBOperations.update(
|
||||||
.update(sshData)
|
sshData,
|
||||||
.set(sshDataObj)
|
'ssh_data',
|
||||||
.where(and(eq(sshData.id, Number(hostId)), eq(sshData.userId, userId)));
|
and(eq(sshData.id, Number(hostId)), eq(sshData.userId, userId)),
|
||||||
|
sshDataObj
|
||||||
|
);
|
||||||
|
|
||||||
const updatedHosts = await db
|
const updatedHosts = await EncryptedDBOperations.select(
|
||||||
.select()
|
db.select().from(sshData).where(and(eq(sshData.id, Number(hostId)), eq(sshData.userId, userId))),
|
||||||
.from(sshData)
|
'ssh_data'
|
||||||
.where(and(eq(sshData.id, Number(hostId)), eq(sshData.userId, userId)));
|
);
|
||||||
|
|
||||||
if (updatedHosts.length === 0) {
|
if (updatedHosts.length === 0) {
|
||||||
sshLogger.warn("Updated host not found after update", {
|
sshLogger.warn("Updated host not found after update", {
|
||||||
@@ -482,10 +484,10 @@ router.get("/db/host", authenticateJWT, async (req: Request, res: Response) => {
|
|||||||
return res.status(400).json({ error: "Invalid userId" });
|
return res.status(400).json({ error: "Invalid userId" });
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const data = await db
|
const data = await EncryptedDBOperations.select(
|
||||||
.select()
|
db.select().from(sshData).where(eq(sshData.userId, userId)),
|
||||||
.from(sshData)
|
'ssh_data'
|
||||||
.where(eq(sshData.userId, userId));
|
);
|
||||||
|
|
||||||
const result = await Promise.all(
|
const result = await Promise.all(
|
||||||
data.map(async (row: any) => {
|
data.map(async (row: any) => {
|
||||||
@@ -1102,14 +1104,15 @@ router.put(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const updatedHosts = await db
|
const updatedHosts = await EncryptedDBOperations.update(
|
||||||
.update(sshData)
|
sshData,
|
||||||
.set({
|
'ssh_data',
|
||||||
|
and(eq(sshData.userId, userId), eq(sshData.folder, oldName)),
|
||||||
|
{
|
||||||
folder: newName,
|
folder: newName,
|
||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
})
|
}
|
||||||
.where(and(eq(sshData.userId, userId), eq(sshData.folder, oldName)))
|
);
|
||||||
.returning();
|
|
||||||
|
|
||||||
const updatedCredentials = await db
|
const updatedCredentials = await db
|
||||||
.update(sshCredentials)
|
.update(sshCredentials)
|
||||||
@@ -1249,7 +1252,7 @@ router.post(
|
|||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
|
|
||||||
await db.insert(sshData).values(sshDataObj);
|
await EncryptedDBOperations.insert(sshData, 'ssh_data', sshDataObj);
|
||||||
results.success++;
|
results.success++;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
results.failed++;
|
results.failed++;
|
||||||
|
|||||||
@@ -1334,6 +1334,130 @@ app.put("/ssh/file_manager/ssh/renameItem", async (req, res) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.post("/ssh/file_manager/ssh/downloadFile", async (req, res) => {
|
||||||
|
const {
|
||||||
|
sessionId,
|
||||||
|
path: filePath,
|
||||||
|
hostId,
|
||||||
|
userId,
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
|
if (!sessionId || !filePath) {
|
||||||
|
fileLogger.warn("Missing download parameters", {
|
||||||
|
operation: "file_download",
|
||||||
|
sessionId,
|
||||||
|
hasFilePath: !!filePath,
|
||||||
|
});
|
||||||
|
return res.status(400).json({ error: "Missing download parameters" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const sshConn = sshSessions[sessionId];
|
||||||
|
if (!sshConn || !sshConn.isConnected) {
|
||||||
|
fileLogger.warn("SSH session not found or not connected for download", {
|
||||||
|
operation: "file_download",
|
||||||
|
sessionId,
|
||||||
|
isConnected: sshConn?.isConnected,
|
||||||
|
});
|
||||||
|
return res.status(400).json({ error: "SSH session not found or not connected" });
|
||||||
|
}
|
||||||
|
|
||||||
|
sshConn.lastActive = Date.now();
|
||||||
|
scheduleSessionCleanup(sessionId);
|
||||||
|
|
||||||
|
// Use SFTP to read file for binary safety
|
||||||
|
sshConn.client.sftp((err, sftp) => {
|
||||||
|
if (err) {
|
||||||
|
fileLogger.error("SFTP connection failed for download:", err);
|
||||||
|
return res.status(500).json({ error: "SFTP connection failed" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get file stats first to check if it's a regular file and get size
|
||||||
|
sftp.stat(filePath, (statErr, stats) => {
|
||||||
|
if (statErr) {
|
||||||
|
fileLogger.error("File stat failed for download:", statErr);
|
||||||
|
return res.status(500).json({ error: `Cannot access file: ${statErr.message}` });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!stats.isFile()) {
|
||||||
|
fileLogger.warn("Attempted to download non-file", {
|
||||||
|
operation: "file_download",
|
||||||
|
sessionId,
|
||||||
|
filePath,
|
||||||
|
isFile: stats.isFile(),
|
||||||
|
isDirectory: stats.isDirectory(),
|
||||||
|
});
|
||||||
|
return res.status(400).json({ error: "Cannot download directories or special files" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check file size (limit to 100MB for safety)
|
||||||
|
const MAX_FILE_SIZE = 100 * 1024 * 1024; // 100MB
|
||||||
|
if (stats.size > MAX_FILE_SIZE) {
|
||||||
|
fileLogger.warn("File too large for download", {
|
||||||
|
operation: "file_download",
|
||||||
|
sessionId,
|
||||||
|
filePath,
|
||||||
|
fileSize: stats.size,
|
||||||
|
maxSize: MAX_FILE_SIZE,
|
||||||
|
});
|
||||||
|
return res.status(400).json({
|
||||||
|
error: `File too large. Maximum size is ${MAX_FILE_SIZE / 1024 / 1024}MB, file is ${(stats.size / 1024 / 1024).toFixed(2)}MB`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read file content
|
||||||
|
sftp.readFile(filePath, (readErr, data) => {
|
||||||
|
if (readErr) {
|
||||||
|
fileLogger.error("File read failed for download:", readErr);
|
||||||
|
return res.status(500).json({ error: `Failed to read file: ${readErr.message}` });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to base64 for safe transport
|
||||||
|
const base64Content = data.toString('base64');
|
||||||
|
const fileName = filePath.split('/').pop() || 'download';
|
||||||
|
|
||||||
|
fileLogger.success("File downloaded successfully", {
|
||||||
|
operation: "file_download",
|
||||||
|
sessionId,
|
||||||
|
filePath,
|
||||||
|
fileName,
|
||||||
|
fileSize: stats.size,
|
||||||
|
hostId,
|
||||||
|
userId,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
content: base64Content,
|
||||||
|
fileName: fileName,
|
||||||
|
size: stats.size,
|
||||||
|
mimeType: getMimeType(fileName),
|
||||||
|
path: filePath,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Helper function to determine MIME type based on file extension
|
||||||
|
function getMimeType(fileName: string): string {
|
||||||
|
const ext = fileName.split('.').pop()?.toLowerCase();
|
||||||
|
const mimeTypes: Record<string, string> = {
|
||||||
|
'txt': 'text/plain',
|
||||||
|
'json': 'application/json',
|
||||||
|
'js': 'text/javascript',
|
||||||
|
'html': 'text/html',
|
||||||
|
'css': 'text/css',
|
||||||
|
'png': 'image/png',
|
||||||
|
'jpg': 'image/jpeg',
|
||||||
|
'jpeg': 'image/jpeg',
|
||||||
|
'gif': 'image/gif',
|
||||||
|
'pdf': 'application/pdf',
|
||||||
|
'zip': 'application/zip',
|
||||||
|
'tar': 'application/x-tar',
|
||||||
|
'gz': 'application/gzip',
|
||||||
|
};
|
||||||
|
return mimeTypes[ext || ''] || 'application/octet-stream';
|
||||||
|
}
|
||||||
|
|
||||||
process.on("SIGINT", () => {
|
process.on("SIGINT", () => {
|
||||||
Object.keys(sshSessions).forEach(cleanupSession);
|
Object.keys(sshSessions).forEach(cleanupSession);
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
|
|||||||
@@ -358,6 +358,17 @@ async function resolveHostCredentials(
|
|||||||
host: any,
|
host: any,
|
||||||
): Promise<SSHHostWithCredentials | undefined> {
|
): Promise<SSHHostWithCredentials | undefined> {
|
||||||
try {
|
try {
|
||||||
|
statsLogger.debug(`Resolving credentials for host ${host.id}`, {
|
||||||
|
operation: 'credential_resolve',
|
||||||
|
hostId: host.id,
|
||||||
|
authType: host.authType,
|
||||||
|
credentialId: host.credentialId,
|
||||||
|
hasPassword: !!host.password,
|
||||||
|
hasKey: !!host.key,
|
||||||
|
passwordLength: host.password?.length || 0,
|
||||||
|
keyLength: host.key?.length || 0
|
||||||
|
});
|
||||||
|
|
||||||
const baseHost: any = {
|
const baseHost: any = {
|
||||||
id: host.id,
|
id: host.id,
|
||||||
name: host.name,
|
name: host.name,
|
||||||
@@ -397,6 +408,16 @@ async function resolveHostCredentials(
|
|||||||
|
|
||||||
if (credentials.length > 0) {
|
if (credentials.length > 0) {
|
||||||
const credential = credentials[0];
|
const credential = credentials[0];
|
||||||
|
statsLogger.debug(`Using credential ${credential.id} for host ${host.id}`, {
|
||||||
|
operation: 'credential_resolve',
|
||||||
|
credentialId: credential.id,
|
||||||
|
authType: credential.authType,
|
||||||
|
hasPassword: !!credential.password,
|
||||||
|
hasKey: !!credential.key,
|
||||||
|
passwordLength: credential.password?.length || 0,
|
||||||
|
keyLength: credential.key?.length || 0
|
||||||
|
});
|
||||||
|
|
||||||
baseHost.credentialId = credential.id;
|
baseHost.credentialId = credential.id;
|
||||||
baseHost.username = credential.username;
|
baseHost.username = credential.username;
|
||||||
baseHost.authType = credential.authType;
|
baseHost.authType = credential.authType;
|
||||||
@@ -426,9 +447,25 @@ async function resolveHostCredentials(
|
|||||||
addLegacyCredentials(baseHost, host);
|
addLegacyCredentials(baseHost, host);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
statsLogger.debug(`Using legacy credentials for host ${host.id}`, {
|
||||||
|
operation: 'credential_resolve',
|
||||||
|
hasPassword: !!host.password,
|
||||||
|
hasKey: !!host.key,
|
||||||
|
passwordLength: host.password?.length || 0,
|
||||||
|
keyLength: host.key?.length || 0
|
||||||
|
});
|
||||||
addLegacyCredentials(baseHost, host);
|
addLegacyCredentials(baseHost, host);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
statsLogger.debug(`Final resolved host ${host.id}`, {
|
||||||
|
operation: 'credential_resolve',
|
||||||
|
authType: baseHost.authType,
|
||||||
|
hasPassword: !!baseHost.password,
|
||||||
|
hasKey: !!baseHost.key,
|
||||||
|
passwordLength: baseHost.password?.length || 0,
|
||||||
|
keyLength: baseHost.key?.length || 0
|
||||||
|
});
|
||||||
|
|
||||||
return baseHost;
|
return baseHost;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
statsLogger.error(
|
statsLogger.error(
|
||||||
@@ -446,6 +483,18 @@ function addLegacyCredentials(baseHost: any, host: any): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildSshConfig(host: SSHHostWithCredentials): ConnectConfig {
|
function buildSshConfig(host: SSHHostWithCredentials): ConnectConfig {
|
||||||
|
statsLogger.debug(`Building SSH config for host ${host.ip}`, {
|
||||||
|
operation: 'ssh_config',
|
||||||
|
authType: host.authType,
|
||||||
|
hasPassword: !!host.password,
|
||||||
|
hasKey: !!host.key,
|
||||||
|
username: host.username,
|
||||||
|
passwordLength: host.password?.length || 0,
|
||||||
|
keyLength: host.key?.length || 0,
|
||||||
|
passwordType: typeof host.password,
|
||||||
|
passwordRaw: host.password ? JSON.stringify(host.password.substring(0, 20)) : null
|
||||||
|
});
|
||||||
|
|
||||||
const base: ConnectConfig = {
|
const base: ConnectConfig = {
|
||||||
host: host.ip,
|
host: host.ip,
|
||||||
port: host.port || 22,
|
port: host.port || 22,
|
||||||
@@ -458,12 +507,26 @@ function buildSshConfig(host: SSHHostWithCredentials): ConnectConfig {
|
|||||||
if (!host.password) {
|
if (!host.password) {
|
||||||
throw new Error(`No password available for host ${host.ip}`);
|
throw new Error(`No password available for host ${host.ip}`);
|
||||||
}
|
}
|
||||||
|
statsLogger.debug(`Using password auth for ${host.ip}`, {
|
||||||
|
operation: 'ssh_config',
|
||||||
|
passwordLength: host.password.length,
|
||||||
|
passwordFirst3: host.password.substring(0, 3),
|
||||||
|
passwordLast3: host.password.substring(host.password.length - 3),
|
||||||
|
passwordType: typeof host.password,
|
||||||
|
passwordIsString: typeof host.password === 'string'
|
||||||
|
});
|
||||||
(base as any).password = host.password;
|
(base as any).password = host.password;
|
||||||
} else if (host.authType === "key") {
|
} else if (host.authType === "key") {
|
||||||
if (!host.key) {
|
if (!host.key) {
|
||||||
throw new Error(`No SSH key available for host ${host.ip}`);
|
throw new Error(`No SSH key available for host ${host.ip}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
statsLogger.debug(`Using key auth for ${host.ip}`, {
|
||||||
|
operation: 'ssh_config',
|
||||||
|
keyPreview: host.key.substring(0, Math.min(50, host.key.length)) + '...',
|
||||||
|
hasPassphrase: !!host.keyPassword
|
||||||
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (!host.key.includes("-----BEGIN") || !host.key.includes("-----END")) {
|
if (!host.key.includes("-----BEGIN") || !host.key.includes("-----END")) {
|
||||||
throw new Error("Invalid private key format");
|
throw new Error("Invalid private key format");
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { db } from "../database/db/index.js";
|
|||||||
import { sshCredentials } from "../database/db/schema.js";
|
import { sshCredentials } from "../database/db/schema.js";
|
||||||
import { eq, and } from "drizzle-orm";
|
import { eq, and } from "drizzle-orm";
|
||||||
import { sshLogger } from "../utils/logger.js";
|
import { sshLogger } from "../utils/logger.js";
|
||||||
|
import { EncryptedDBOperations } from "../utils/encrypted-db-operations.js";
|
||||||
|
|
||||||
const wss = new WebSocketServer({ port: 8082 });
|
const wss = new WebSocketServer({ port: 8082 });
|
||||||
|
|
||||||
@@ -174,18 +175,38 @@ wss.on("connection", (ws: WebSocket) => {
|
|||||||
}
|
}
|
||||||
}, 60000);
|
}, 60000);
|
||||||
|
|
||||||
|
sshLogger.debug(`Terminal SSH setup`, {
|
||||||
|
operation: 'terminal_ssh',
|
||||||
|
hostId: id,
|
||||||
|
ip,
|
||||||
|
authType,
|
||||||
|
hasPassword: !!password,
|
||||||
|
passwordLength: password?.length || 0,
|
||||||
|
hasCredentialId: !!credentialId
|
||||||
|
});
|
||||||
|
|
||||||
|
if (password) {
|
||||||
|
sshLogger.debug(`Password preview: "${password.substring(0, 15)}..."`, {
|
||||||
|
operation: 'terminal_ssh_password'
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
sshLogger.debug(`No password provided`, {
|
||||||
|
operation: 'terminal_ssh_password'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
let resolvedCredentials = { password, key, keyPassword, keyType, authType };
|
let resolvedCredentials = { password, key, keyPassword, keyType, authType };
|
||||||
if (credentialId && id && hostConfig.userId) {
|
if (credentialId && id && hostConfig.userId) {
|
||||||
try {
|
try {
|
||||||
const credentials = await db
|
const credentials = await EncryptedDBOperations.select(
|
||||||
.select()
|
db.select().from(sshCredentials).where(
|
||||||
.from(sshCredentials)
|
|
||||||
.where(
|
|
||||||
and(
|
and(
|
||||||
eq(sshCredentials.id, credentialId),
|
eq(sshCredentials.id, credentialId),
|
||||||
eq(sshCredentials.userId, hostConfig.userId),
|
eq(sshCredentials.userId, hostConfig.userId),
|
||||||
),
|
),
|
||||||
);
|
),
|
||||||
|
'ssh_credentials'
|
||||||
|
);
|
||||||
|
|
||||||
if (credentials.length > 0) {
|
if (credentials.length > 0) {
|
||||||
const credential = credentials[0];
|
const credential = credentials[0];
|
||||||
|
|||||||
@@ -581,6 +581,7 @@
|
|||||||
"folder": "Folder",
|
"folder": "Folder",
|
||||||
"connectToSsh": "Connect to SSH to use file operations",
|
"connectToSsh": "Connect to SSH to use file operations",
|
||||||
"uploadFile": "Upload File",
|
"uploadFile": "Upload File",
|
||||||
|
"downloadFile": "Download File",
|
||||||
"newFile": "New File",
|
"newFile": "New File",
|
||||||
"newFolder": "New Folder",
|
"newFolder": "New Folder",
|
||||||
"rename": "Rename",
|
"rename": "Rename",
|
||||||
@@ -593,7 +594,9 @@
|
|||||||
"clickToSelectFile": "Click to select a file",
|
"clickToSelectFile": "Click to select a file",
|
||||||
"chooseFile": "Choose File",
|
"chooseFile": "Choose File",
|
||||||
"uploading": "Uploading...",
|
"uploading": "Uploading...",
|
||||||
|
"downloading": "Downloading...",
|
||||||
"uploadingFile": "Uploading {{name}}...",
|
"uploadingFile": "Uploading {{name}}...",
|
||||||
|
"downloadingFile": "Downloading {{name}}...",
|
||||||
"creatingFile": "Creating {{name}}...",
|
"creatingFile": "Creating {{name}}...",
|
||||||
"creatingFolder": "Creating {{name}}...",
|
"creatingFolder": "Creating {{name}}...",
|
||||||
"deletingItem": "Deleting {{type}} {{name}}...",
|
"deletingItem": "Deleting {{type}} {{name}}...",
|
||||||
@@ -615,6 +618,10 @@
|
|||||||
"renaming": "Renaming...",
|
"renaming": "Renaming...",
|
||||||
"fileUploadedSuccessfully": "File \"{{name}}\" uploaded successfully",
|
"fileUploadedSuccessfully": "File \"{{name}}\" uploaded successfully",
|
||||||
"failedToUploadFile": "Failed to upload file",
|
"failedToUploadFile": "Failed to upload file",
|
||||||
|
"fileDownloadedSuccessfully": "File \"{{name}}\" downloaded successfully",
|
||||||
|
"failedToDownloadFile": "Failed to download file",
|
||||||
|
"noFileContent": "No file content received",
|
||||||
|
"filePath": "File Path",
|
||||||
"fileCreatedSuccessfully": "File \"{{name}}\" created successfully",
|
"fileCreatedSuccessfully": "File \"{{name}}\" created successfully",
|
||||||
"failedToCreateFile": "Failed to create file",
|
"failedToCreateFile": "Failed to create file",
|
||||||
"folderCreatedSuccessfully": "Folder \"{{name}}\" created successfully",
|
"folderCreatedSuccessfully": "Folder \"{{name}}\" created successfully",
|
||||||
|
|||||||
@@ -596,6 +596,7 @@
|
|||||||
"folder": "文件夹",
|
"folder": "文件夹",
|
||||||
"connectToSsh": "连接 SSH 以使用文件操作",
|
"connectToSsh": "连接 SSH 以使用文件操作",
|
||||||
"uploadFile": "上传文件",
|
"uploadFile": "上传文件",
|
||||||
|
"downloadFile": "下载文件",
|
||||||
"newFile": "新建文件",
|
"newFile": "新建文件",
|
||||||
"newFolder": "新建文件夹",
|
"newFolder": "新建文件夹",
|
||||||
"rename": "重命名",
|
"rename": "重命名",
|
||||||
@@ -608,7 +609,9 @@
|
|||||||
"clickToSelectFile": "点击选择文件",
|
"clickToSelectFile": "点击选择文件",
|
||||||
"chooseFile": "选择文件",
|
"chooseFile": "选择文件",
|
||||||
"uploading": "上传中...",
|
"uploading": "上传中...",
|
||||||
|
"downloading": "下载中...",
|
||||||
"uploadingFile": "正在上传 {{name}}...",
|
"uploadingFile": "正在上传 {{name}}...",
|
||||||
|
"downloadingFile": "正在下载 {{name}}...",
|
||||||
"creatingFile": "正在创建 {{name}}...",
|
"creatingFile": "正在创建 {{name}}...",
|
||||||
"creatingFolder": "正在创建 {{name}}...",
|
"creatingFolder": "正在创建 {{name}}...",
|
||||||
"deletingItem": "正在删除 {{type}} {{name}}...",
|
"deletingItem": "正在删除 {{type}} {{name}}...",
|
||||||
@@ -630,6 +633,10 @@
|
|||||||
"renaming": "重命名中...",
|
"renaming": "重命名中...",
|
||||||
"fileUploadedSuccessfully": "文件 \"{{name}}\" 上传成功",
|
"fileUploadedSuccessfully": "文件 \"{{name}}\" 上传成功",
|
||||||
"failedToUploadFile": "上传文件失败",
|
"failedToUploadFile": "上传文件失败",
|
||||||
|
"fileDownloadedSuccessfully": "文件 \"{{name}}\" 下载成功",
|
||||||
|
"failedToDownloadFile": "下载文件失败",
|
||||||
|
"noFileContent": "未收到文件内容",
|
||||||
|
"filePath": "文件路径",
|
||||||
"fileCreatedSuccessfully": "文件 \"{{name}}\" 创建成功",
|
"fileCreatedSuccessfully": "文件 \"{{name}}\" 创建成功",
|
||||||
"failedToCreateFile": "创建文件失败",
|
"failedToCreateFile": "创建文件失败",
|
||||||
"folderCreatedSuccessfully": "文件夹 \"{{name}}\" 创建成功",
|
"folderCreatedSuccessfully": "文件夹 \"{{name}}\" 创建成功",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { Button } from "@/components/ui/button.tsx";
|
import { Button } from "@/components/ui/button.tsx";
|
||||||
import { Card } from "@/components/ui/card.tsx";
|
import { Card } from "@/components/ui/card.tsx";
|
||||||
import { Folder, File, Trash2, Pin } from "lucide-react";
|
import { Folder, File, Trash2, Pin, Download } from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
interface SSHConnection {
|
interface SSHConnection {
|
||||||
@@ -32,6 +32,7 @@ interface FileManagerLeftSidebarVileViewerProps {
|
|||||||
onOpenFile: (file: FileItem) => void;
|
onOpenFile: (file: FileItem) => void;
|
||||||
onOpenFolder: (folder: FileItem) => void;
|
onOpenFolder: (folder: FileItem) => void;
|
||||||
onStarFile: (file: FileItem) => void;
|
onStarFile: (file: FileItem) => void;
|
||||||
|
onDownloadFile?: (file: FileItem) => void;
|
||||||
onDeleteFile: (file: FileItem) => void;
|
onDeleteFile: (file: FileItem) => void;
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
error?: string;
|
error?: string;
|
||||||
@@ -47,6 +48,7 @@ export function FileManagerLeftSidebarFileViewer({
|
|||||||
onOpenFile,
|
onOpenFile,
|
||||||
onOpenFolder,
|
onOpenFolder,
|
||||||
onStarFile,
|
onStarFile,
|
||||||
|
onDownloadFile,
|
||||||
onDeleteFile,
|
onDeleteFile,
|
||||||
isLoading,
|
isLoading,
|
||||||
error,
|
error,
|
||||||
@@ -104,6 +106,17 @@ export function FileManagerLeftSidebarFileViewer({
|
|||||||
className={`w-4 h-4 ${item.isStarred ? "text-yellow-400" : "text-muted-foreground"}`}
|
className={`w-4 h-4 ${item.isStarred ? "text-yellow-400" : "text-muted-foreground"}`}
|
||||||
/>
|
/>
|
||||||
</Button>
|
</Button>
|
||||||
|
{item.type === "file" && onDownloadFile && (
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-7 w-7"
|
||||||
|
onClick={() => onDownloadFile(item)}
|
||||||
|
title={t("fileManager.downloadFile")}
|
||||||
|
>
|
||||||
|
<Download className="w-4 h-4 text-blue-400" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
size="icon"
|
size="icon"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Card } from "@/components/ui/card.tsx";
|
|||||||
import { Separator } from "@/components/ui/separator.tsx";
|
import { Separator } from "@/components/ui/separator.tsx";
|
||||||
import {
|
import {
|
||||||
Upload,
|
Upload,
|
||||||
|
Download,
|
||||||
FilePlus,
|
FilePlus,
|
||||||
FolderPlus,
|
FolderPlus,
|
||||||
Trash2,
|
Trash2,
|
||||||
@@ -27,12 +28,14 @@ export function FileManagerOperations({
|
|||||||
}: FileManagerOperationsProps) {
|
}: FileManagerOperationsProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [showUpload, setShowUpload] = useState(false);
|
const [showUpload, setShowUpload] = useState(false);
|
||||||
|
const [showDownload, setShowDownload] = useState(false);
|
||||||
const [showCreateFile, setShowCreateFile] = useState(false);
|
const [showCreateFile, setShowCreateFile] = useState(false);
|
||||||
const [showCreateFolder, setShowCreateFolder] = useState(false);
|
const [showCreateFolder, setShowCreateFolder] = useState(false);
|
||||||
const [showDelete, setShowDelete] = useState(false);
|
const [showDelete, setShowDelete] = useState(false);
|
||||||
const [showRename, setShowRename] = useState(false);
|
const [showRename, setShowRename] = useState(false);
|
||||||
|
|
||||||
const [uploadFile, setUploadFile] = useState<File | null>(null);
|
const [uploadFile, setUploadFile] = useState<File | null>(null);
|
||||||
|
const [downloadPath, setDownloadPath] = useState("");
|
||||||
const [newFileName, setNewFileName] = useState("");
|
const [newFileName, setNewFileName] = useState("");
|
||||||
const [newFolderName, setNewFolderName] = useState("");
|
const [newFolderName, setNewFolderName] = useState("");
|
||||||
const [deletePath, setDeletePath] = useState("");
|
const [deletePath, setDeletePath] = useState("");
|
||||||
@@ -154,6 +157,66 @@ export function FileManagerOperations({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleDownload = async () => {
|
||||||
|
if (!downloadPath.trim() || !sshSessionId) return;
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
const { toast } = await import("sonner");
|
||||||
|
const fileName = downloadPath.split('/').pop() || 'download';
|
||||||
|
const loadingToast = toast.loading(
|
||||||
|
t("fileManager.downloadingFile", { name: fileName }),
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { downloadSSHFile } = await import("@/ui/main-axios.ts");
|
||||||
|
|
||||||
|
const response = await downloadSSHFile(
|
||||||
|
sshSessionId,
|
||||||
|
downloadPath.trim(),
|
||||||
|
);
|
||||||
|
|
||||||
|
toast.dismiss(loadingToast);
|
||||||
|
|
||||||
|
if (response?.content) {
|
||||||
|
// Convert base64 to blob and trigger download
|
||||||
|
const byteCharacters = atob(response.content);
|
||||||
|
const byteNumbers = new Array(byteCharacters.length);
|
||||||
|
for (let i = 0; i < byteCharacters.length; i++) {
|
||||||
|
byteNumbers[i] = byteCharacters.charCodeAt(i);
|
||||||
|
}
|
||||||
|
const byteArray = new Uint8Array(byteNumbers);
|
||||||
|
const blob = new Blob([byteArray], { type: response.mimeType || 'application/octet-stream' });
|
||||||
|
|
||||||
|
// Create download link
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = response.fileName || fileName;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
|
||||||
|
onSuccess(
|
||||||
|
t("fileManager.fileDownloadedSuccessfully", { name: response.fileName || fileName }),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
onError(t("fileManager.noFileContent"));
|
||||||
|
}
|
||||||
|
|
||||||
|
setShowDownload(false);
|
||||||
|
setDownloadPath("");
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.dismiss(loadingToast);
|
||||||
|
onError(
|
||||||
|
error?.response?.data?.error || t("fileManager.failedToDownloadFile"),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleCreateFolder = async () => {
|
const handleCreateFolder = async () => {
|
||||||
if (!newFolderName.trim() || !sshSessionId) return;
|
if (!newFolderName.trim() || !sshSessionId) return;
|
||||||
|
|
||||||
@@ -344,7 +407,7 @@ export function FileManagerOperations({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={containerRef} className="p-4 space-y-4">
|
<div ref={containerRef} className="p-4 space-y-4">
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-3 gap-2">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -357,6 +420,18 @@ export function FileManagerOperations({
|
|||||||
<span className="truncate">{t("fileManager.uploadFile")}</span>
|
<span className="truncate">{t("fileManager.uploadFile")}</span>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setShowDownload(true)}
|
||||||
|
className="h-10 bg-dark-bg border-2 border-dark-border hover:border-dark-border-hover hover:bg-dark-hover"
|
||||||
|
title={t("fileManager.downloadFile")}
|
||||||
|
>
|
||||||
|
<Download className={cn("w-4 h-4", showTextLabels ? "mr-2" : "")} />
|
||||||
|
{showTextLabels && (
|
||||||
|
<span className="truncate">{t("fileManager.downloadFile")}</span>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -397,7 +472,7 @@ export function FileManagerOperations({
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setShowDelete(true)}
|
onClick={() => setShowDelete(true)}
|
||||||
className="h-10 bg-dark-bg border-2 border-dark-border hover:border-dark-border-hover hover:bg-dark-hover col-span-2"
|
className="h-10 bg-dark-bg border-2 border-dark-border hover:border-dark-border-hover hover:bg-dark-hover col-span-3"
|
||||||
title={t("fileManager.deleteItem")}
|
title={t("fileManager.deleteItem")}
|
||||||
>
|
>
|
||||||
<Trash2 className={cn("w-4 h-4", showTextLabels ? "mr-2" : "")} />
|
<Trash2 className={cn("w-4 h-4", showTextLabels ? "mr-2" : "")} />
|
||||||
@@ -516,6 +591,64 @@ export function FileManagerOperations({
|
|||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{showDownload && (
|
||||||
|
<Card className="bg-dark-bg border-2 border-dark-border p-3 sm:p-4">
|
||||||
|
<div className="flex items-start justify-between mb-3">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h3 className="text-base sm:text-lg font-semibold text-white flex items-center gap-2">
|
||||||
|
<Download className="w-5 h-5 sm:w-6 sm:h-6 flex-shrink-0" />
|
||||||
|
<span className="break-words">
|
||||||
|
{t("fileManager.downloadFile")}
|
||||||
|
</span>
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setShowDownload(false)}
|
||||||
|
className="h-8 w-8 p-0 flex-shrink-0 ml-2"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium text-white mb-2 block">
|
||||||
|
{t("fileManager.filePath")}
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
value={downloadPath}
|
||||||
|
onChange={(e) => setDownloadPath(e.target.value)}
|
||||||
|
placeholder={t("placeholders.fullPath")}
|
||||||
|
className="bg-dark-bg-button border-2 border-dark-border-hover text-white text-sm"
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && handleDownload()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Button
|
||||||
|
onClick={handleDownload}
|
||||||
|
disabled={!downloadPath.trim() || isLoading}
|
||||||
|
className="w-full text-sm h-9"
|
||||||
|
>
|
||||||
|
{isLoading
|
||||||
|
? t("fileManager.downloading")
|
||||||
|
: t("fileManager.downloadFile")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setShowDownload(false)}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="w-full text-sm h-9"
|
||||||
|
>
|
||||||
|
{t("common.cancel")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
{showCreateFile && (
|
{showCreateFile && (
|
||||||
<Card className="bg-dark-bg border-2 border-dark-border p-3 sm:p-4">
|
<Card className="bg-dark-bg border-2 border-dark-border p-3 sm:p-4">
|
||||||
<div className="flex items-start justify-between mb-3">
|
<div className="flex items-start justify-between mb-3">
|
||||||
|
|||||||
@@ -1050,6 +1050,25 @@ export async function uploadSSHFile(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function downloadSSHFile(
|
||||||
|
sessionId: string,
|
||||||
|
filePath: string,
|
||||||
|
hostId?: number,
|
||||||
|
userId?: string,
|
||||||
|
): Promise<any> {
|
||||||
|
try {
|
||||||
|
const response = await fileManagerApi.post("/ssh/downloadFile", {
|
||||||
|
sessionId,
|
||||||
|
path: filePath,
|
||||||
|
hostId,
|
||||||
|
userId,
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
handleApiError(error, "download SSH file");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function createSSHFile(
|
export async function createSSHFile(
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
path: string,
|
path: string,
|
||||||
|
|||||||
Reference in New Issue
Block a user