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:
ZacharyZcR
2025-09-16 13:24:25 +08:00
parent 182b60a428
commit 957bc5e41b
10 changed files with 426 additions and 30 deletions

View File

@@ -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"

View File

@@ -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">