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)",
|
||||
);
|
||||
|
||||
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");
|
||||
|
||||
@@ -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++;
|
||||
|
||||
@@ -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", () => {
|
||||
Object.keys(sshSessions).forEach(cleanupSession);
|
||||
process.exit(0);
|
||||
|
||||
@@ -358,6 +358,17 @@ async function resolveHostCredentials(
|
||||
host: any,
|
||||
): Promise<SSHHostWithCredentials | undefined> {
|
||||
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");
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}}\" 创建成功",
|
||||
|
||||
@@ -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"}`}
|
||||
/>
|
||||
</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
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Card } from "@/components/ui/card.tsx";
|
||||
import { Separator } from "@/components/ui/separator.tsx";
|
||||
import {
|
||||
Upload,
|
||||
Download,
|
||||
FilePlus,
|
||||
FolderPlus,
|
||||
Trash2,
|
||||
@@ -27,12 +28,14 @@ export function FileManagerOperations({
|
||||
}: FileManagerOperationsProps) {
|
||||
const { t } = useTranslation();
|
||||
const [showUpload, setShowUpload] = useState(false);
|
||||
const [showDownload, setShowDownload] = useState(false);
|
||||
const [showCreateFile, setShowCreateFile] = useState(false);
|
||||
const [showCreateFolder, setShowCreateFolder] = useState(false);
|
||||
const [showDelete, setShowDelete] = useState(false);
|
||||
const [showRename, setShowRename] = useState(false);
|
||||
|
||||
const [uploadFile, setUploadFile] = useState<File | null>(null);
|
||||
const [downloadPath, setDownloadPath] = useState("");
|
||||
const [newFileName, setNewFileName] = useState("");
|
||||
const [newFolderName, setNewFolderName] = 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 () => {
|
||||
if (!newFolderName.trim() || !sshSessionId) return;
|
||||
|
||||
@@ -344,7 +407,7 @@ export function FileManagerOperations({
|
||||
|
||||
return (
|
||||
<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
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@@ -357,6 +420,18 @@ export function FileManagerOperations({
|
||||
<span className="truncate">{t("fileManager.uploadFile")}</span>
|
||||
)}
|
||||
</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
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@@ -397,7 +472,7 @@ export function FileManagerOperations({
|
||||
variant="outline"
|
||||
size="sm"
|
||||
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")}
|
||||
>
|
||||
<Trash2 className={cn("w-4 h-4", showTextLabels ? "mr-2" : "")} />
|
||||
@@ -516,6 +591,64 @@ export function FileManagerOperations({
|
||||
</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 && (
|
||||
<Card className="bg-dark-bg border-2 border-dark-border p-3 sm:p-4">
|
||||
<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(
|
||||
sessionId: string,
|
||||
path: string,
|
||||
|
||||
Reference in New Issue
Block a user