diff --git a/src/backend/database/db/index.ts b/src/backend/database/db/index.ts index 369541e5..bf3d54ed 100644 --- a/src/backend/database/db/index.ts +++ b/src/backend/database/db/index.ts @@ -250,6 +250,12 @@ const migrateSchema = () => { "INTEGER REFERENCES ssh_credentials(id)", ); + addColumnIfNotExists( + "ssh_data", + "require_password", + "INTEGER NOT NULL DEFAULT 1", + ); + // SSH credentials table migrations for encryption support addColumnIfNotExists("ssh_credentials", "private_key", "TEXT"); addColumnIfNotExists("ssh_credentials", "public_key", "TEXT"); diff --git a/src/backend/database/routes/ssh.ts b/src/backend/database/routes/ssh.ts index 516ee924..26c209b1 100644 --- a/src/backend/database/routes/ssh.ts +++ b/src/backend/database/routes/ssh.ts @@ -210,9 +210,9 @@ router.post( } 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", { operation: "host_create", userId, @@ -223,7 +223,7 @@ router.post( return res.status(500).json({ error: "Failed to create host" }); } - const createdHost = result[0]; + const createdHost = result; const baseHost = { ...createdHost, tags: @@ -401,15 +401,17 @@ router.put( } try { - await db - .update(sshData) - .set(sshDataObj) - .where(and(eq(sshData.id, Number(hostId)), eq(sshData.userId, userId))); + await EncryptedDBOperations.update( + sshData, + 'ssh_data', + and(eq(sshData.id, Number(hostId)), eq(sshData.userId, userId)), + sshDataObj + ); - const updatedHosts = await db - .select() - .from(sshData) - .where(and(eq(sshData.id, Number(hostId)), eq(sshData.userId, userId))); + const updatedHosts = await EncryptedDBOperations.select( + db.select().from(sshData).where(and(eq(sshData.id, Number(hostId)), eq(sshData.userId, userId))), + 'ssh_data' + ); if (updatedHosts.length === 0) { 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" }); } try { - const data = await db - .select() - .from(sshData) - .where(eq(sshData.userId, userId)); + const data = await EncryptedDBOperations.select( + db.select().from(sshData).where(eq(sshData.userId, userId)), + 'ssh_data' + ); const result = await Promise.all( data.map(async (row: any) => { @@ -1102,14 +1104,15 @@ router.put( } try { - const updatedHosts = await db - .update(sshData) - .set({ + const updatedHosts = await EncryptedDBOperations.update( + sshData, + 'ssh_data', + and(eq(sshData.userId, userId), eq(sshData.folder, oldName)), + { folder: newName, updatedAt: new Date().toISOString(), - }) - .where(and(eq(sshData.userId, userId), eq(sshData.folder, oldName))) - .returning(); + } + ); const updatedCredentials = await db .update(sshCredentials) @@ -1249,7 +1252,7 @@ router.post( updatedAt: new Date().toISOString(), }; - await db.insert(sshData).values(sshDataObj); + await EncryptedDBOperations.insert(sshData, 'ssh_data', sshDataObj); results.success++; } catch (error) { results.failed++; diff --git a/src/backend/ssh/file-manager.ts b/src/backend/ssh/file-manager.ts index 1476dd28..9614a93d 100644 --- a/src/backend/ssh/file-manager.ts +++ b/src/backend/ssh/file-manager.ts @@ -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 = { + 'txt': 'text/plain', + 'json': 'application/json', + 'js': 'text/javascript', + 'html': 'text/html', + 'css': 'text/css', + 'png': 'image/png', + 'jpg': 'image/jpeg', + 'jpeg': 'image/jpeg', + 'gif': 'image/gif', + 'pdf': 'application/pdf', + 'zip': 'application/zip', + 'tar': 'application/x-tar', + 'gz': 'application/gzip', + }; + return mimeTypes[ext || ''] || 'application/octet-stream'; +} + process.on("SIGINT", () => { Object.keys(sshSessions).forEach(cleanupSession); process.exit(0); diff --git a/src/backend/ssh/server-stats.ts b/src/backend/ssh/server-stats.ts index 7f9150b4..7437a278 100644 --- a/src/backend/ssh/server-stats.ts +++ b/src/backend/ssh/server-stats.ts @@ -358,6 +358,17 @@ async function resolveHostCredentials( host: any, ): Promise { 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 = { id: host.id, name: host.name, @@ -397,6 +408,16 @@ async function resolveHostCredentials( if (credentials.length > 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.username = credential.username; baseHost.authType = credential.authType; @@ -426,9 +447,25 @@ async function resolveHostCredentials( addLegacyCredentials(baseHost, host); } } 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); } + 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; } catch (error) { statsLogger.error( @@ -446,6 +483,18 @@ function addLegacyCredentials(baseHost: any, host: any): void { } 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 = { host: host.ip, port: host.port || 22, @@ -458,12 +507,26 @@ function buildSshConfig(host: SSHHostWithCredentials): ConnectConfig { if (!host.password) { 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; } else if (host.authType === "key") { if (!host.key) { 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 { if (!host.key.includes("-----BEGIN") || !host.key.includes("-----END")) { throw new Error("Invalid private key format"); diff --git a/src/backend/ssh/terminal.ts b/src/backend/ssh/terminal.ts index 638bebc2..a95bdabb 100644 --- a/src/backend/ssh/terminal.ts +++ b/src/backend/ssh/terminal.ts @@ -4,6 +4,7 @@ import { db } from "../database/db/index.js"; import { sshCredentials } from "../database/db/schema.js"; import { eq, and } from "drizzle-orm"; import { sshLogger } from "../utils/logger.js"; +import { EncryptedDBOperations } from "../utils/encrypted-db-operations.js"; const wss = new WebSocketServer({ port: 8082 }); @@ -174,18 +175,38 @@ wss.on("connection", (ws: WebSocket) => { } }, 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 }; if (credentialId && id && hostConfig.userId) { try { - const credentials = await db - .select() - .from(sshCredentials) - .where( + const credentials = await EncryptedDBOperations.select( + db.select().from(sshCredentials).where( and( eq(sshCredentials.id, credentialId), eq(sshCredentials.userId, hostConfig.userId), ), - ); + ), + 'ssh_credentials' + ); if (credentials.length > 0) { const credential = credentials[0]; diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 17449026..853bc4c6 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -581,6 +581,7 @@ "folder": "Folder", "connectToSsh": "Connect to SSH to use file operations", "uploadFile": "Upload File", + "downloadFile": "Download File", "newFile": "New File", "newFolder": "New Folder", "rename": "Rename", @@ -593,7 +594,9 @@ "clickToSelectFile": "Click to select a file", "chooseFile": "Choose File", "uploading": "Uploading...", + "downloading": "Downloading...", "uploadingFile": "Uploading {{name}}...", + "downloadingFile": "Downloading {{name}}...", "creatingFile": "Creating {{name}}...", "creatingFolder": "Creating {{name}}...", "deletingItem": "Deleting {{type}} {{name}}...", @@ -615,6 +618,10 @@ "renaming": "Renaming...", "fileUploadedSuccessfully": "File \"{{name}}\" uploaded successfully", "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", "failedToCreateFile": "Failed to create file", "folderCreatedSuccessfully": "Folder \"{{name}}\" created successfully", diff --git a/src/locales/zh/translation.json b/src/locales/zh/translation.json index 1fbd14ee..0562d509 100644 --- a/src/locales/zh/translation.json +++ b/src/locales/zh/translation.json @@ -596,6 +596,7 @@ "folder": "文件夹", "connectToSsh": "连接 SSH 以使用文件操作", "uploadFile": "上传文件", + "downloadFile": "下载文件", "newFile": "新建文件", "newFolder": "新建文件夹", "rename": "重命名", @@ -608,7 +609,9 @@ "clickToSelectFile": "点击选择文件", "chooseFile": "选择文件", "uploading": "上传中...", + "downloading": "下载中...", "uploadingFile": "正在上传 {{name}}...", + "downloadingFile": "正在下载 {{name}}...", "creatingFile": "正在创建 {{name}}...", "creatingFolder": "正在创建 {{name}}...", "deletingItem": "正在删除 {{type}} {{name}}...", @@ -630,6 +633,10 @@ "renaming": "重命名中...", "fileUploadedSuccessfully": "文件 \"{{name}}\" 上传成功", "failedToUploadFile": "上传文件失败", + "fileDownloadedSuccessfully": "文件 \"{{name}}\" 下载成功", + "failedToDownloadFile": "下载文件失败", + "noFileContent": "未收到文件内容", + "filePath": "文件路径", "fileCreatedSuccessfully": "文件 \"{{name}}\" 创建成功", "failedToCreateFile": "创建文件失败", "folderCreatedSuccessfully": "文件夹 \"{{name}}\" 创建成功", diff --git a/src/ui/Desktop/Apps/File Manager/FileManagerLeftSidebarFileViewer.tsx b/src/ui/Desktop/Apps/File Manager/FileManagerLeftSidebarFileViewer.tsx index cf88b044..36a23a79 100644 --- a/src/ui/Desktop/Apps/File Manager/FileManagerLeftSidebarFileViewer.tsx +++ b/src/ui/Desktop/Apps/File Manager/FileManagerLeftSidebarFileViewer.tsx @@ -1,7 +1,7 @@ import React from "react"; import { Button } from "@/components/ui/button.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"; interface SSHConnection { @@ -32,6 +32,7 @@ interface FileManagerLeftSidebarVileViewerProps { onOpenFile: (file: FileItem) => void; onOpenFolder: (folder: FileItem) => void; onStarFile: (file: FileItem) => void; + onDownloadFile?: (file: FileItem) => void; onDeleteFile: (file: FileItem) => void; isLoading?: boolean; error?: string; @@ -47,6 +48,7 @@ export function FileManagerLeftSidebarFileViewer({ onOpenFile, onOpenFolder, onStarFile, + onDownloadFile, onDeleteFile, isLoading, error, @@ -104,6 +106,17 @@ export function FileManagerLeftSidebarFileViewer({ className={`w-4 h-4 ${item.isStarred ? "text-yellow-400" : "text-muted-foreground"}`} /> + {item.type === "file" && onDownloadFile && ( + + )} + + + +
+
+ + 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()} + /> +
+ +
+ + +
+
+ + )} + {showCreateFile && (
diff --git a/src/ui/main-axios.ts b/src/ui/main-axios.ts index 5d051d1a..b27186d3 100644 --- a/src/ui/main-axios.ts +++ b/src/ui/main-axios.ts @@ -1050,6 +1050,25 @@ export async function uploadSSHFile( } } +export async function downloadSSHFile( + sessionId: string, + filePath: string, + hostId?: number, + userId?: string, +): Promise { + 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( sessionId: string, path: string,