diff --git a/src/backend/ssh/file-manager.ts b/src/backend/ssh/file-manager.ts index 70b0e22d..b2ee2138 100644 --- a/src/backend/ssh/file-manager.ts +++ b/src/backend/ssh/file-manager.ts @@ -2490,6 +2490,103 @@ app.post("/ssh/file_manager/ssh/executeFile", async (req, res) => { }); }); +app.post("/ssh/file_manager/ssh/changePermissions", async (req, res) => { + const { sessionId, path, permissions } = req.body; + const sshConn = sshSessions[sessionId]; + + if (!sshConn || !sshConn.isConnected) { + fileLogger.error( + "SSH connection not found or not connected for changePermissions", + { + operation: "change_permissions", + sessionId, + hasConnection: !!sshConn, + isConnected: sshConn?.isConnected, + }, + ); + return res.status(400).json({ error: "SSH connection not available" }); + } + + 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)" + }); + } + + const octalPerms = permissions.slice(-3); + const escapedPath = path.replace(/'/g, "'\"'\"'"); + const command = `chmod ${octalPerms} '${escapedPath}'`; + + fileLogger.info("Changing file permissions", { + operation: "change_permissions", + sessionId, + path, + permissions: octalPerms, + }); + + sshConn.client.exec(command, (err, stream) => { + if (err) { + fileLogger.error("SSH changePermissions exec error:", err, { + operation: "change_permissions", + sessionId, + path, + permissions: octalPerms, + }); + return res.status(500).json({ error: "Failed to change permissions" }); + } + + let errorOutput = ""; + + stream.stderr.on("data", (data) => { + errorOutput += data.toString(); + }); + + stream.on("close", (code) => { + if (code !== 0) { + fileLogger.error("chmod command failed", { + operation: "change_permissions", + sessionId, + path, + permissions: octalPerms, + exitCode: code, + error: errorOutput, + }); + return res.status(500).json({ + error: errorOutput || "Failed to change permissions" + }); + } + + fileLogger.success("File permissions changed successfully", { + operation: "change_permissions", + sessionId, + path, + permissions: octalPerms, + }); + + res.json({ + success: true, + message: "Permissions changed successfully" + }); + }); + + stream.on("error", (streamErr) => { + fileLogger.error("SSH changePermissions stream error:", streamErr, { + operation: "change_permissions", + sessionId, + path, + permissions: octalPerms, + }); + if (!res.headersSent) { + res.status(500).json({ error: "Stream error while changing permissions" }); + } + }); + }); +}); + process.on("SIGINT", () => { Object.keys(sshSessions).forEach(cleanupSession); process.exit(0); diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 0fe90342..485c2293 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -1169,7 +1169,19 @@ "sshConnectionFailed": "SSH connection failed. Please check your connection to {{name}} ({{ip}}:{{port}})", "loadFileFailed": "Failed to load file: {{error}}", "connectedSuccessfully": "Connected successfully", - "totpVerificationFailed": "TOTP verification failed" + "totpVerificationFailed": "TOTP verification failed", + "changePermissions": "Change Permissions", + "changePermissionsDesc": "Modify file permissions for", + "currentPermissions": "Current Permissions", + "newPermissions": "New Permissions", + "owner": "Owner", + "group": "Group", + "others": "Others", + "read": "Read", + "write": "Write", + "execute": "Execute", + "permissionsChangedSuccessfully": "Permissions changed successfully", + "failedToChangePermissions": "Failed to change permissions" }, "tunnels": { "title": "SSH Tunnels", diff --git a/src/locales/zh/translation.json b/src/locales/zh/translation.json index 5561e41c..9150f81d 100644 --- a/src/locales/zh/translation.json +++ b/src/locales/zh/translation.json @@ -1151,7 +1151,19 @@ "sshConnectionFailed": "SSH 连接失败。请检查与 {{name}} ({{ip}}:{{port}}) 的连接", "loadFileFailed": "加载文件失败:{{error}}", "connectedSuccessfully": "连接成功", - "totpVerificationFailed": "TOTP 验证失败" + "totpVerificationFailed": "TOTP 验证失败", + "changePermissions": "修改权限", + "changePermissionsDesc": "修改文件权限", + "currentPermissions": "当前权限", + "newPermissions": "新权限", + "owner": "所有者", + "group": "组", + "others": "其他", + "read": "读取", + "write": "写入", + "execute": "执行", + "permissionsChangedSuccessfully": "权限修改成功", + "failedToChangePermissions": "权限修改失败" }, "tunnels": { "title": "SSH 隧道", diff --git a/src/ui/desktop/apps/file-manager/FileManager.tsx b/src/ui/desktop/apps/file-manager/FileManager.tsx index 72a56006..7c0e8208 100644 --- a/src/ui/desktop/apps/file-manager/FileManager.tsx +++ b/src/ui/desktop/apps/file-manager/FileManager.tsx @@ -16,6 +16,7 @@ import { toast } from "sonner"; import { useTranslation } from "react-i18next"; import { TOTPDialog } from "@/ui/desktop/navigation/TOTPDialog.tsx"; import { SSHAuthDialog } from "@/ui/desktop/navigation/SSHAuthDialog.tsx"; +import { PermissionsDialog } from "./components/PermissionsDialog"; import { Upload, FolderPlus, @@ -49,6 +50,7 @@ import { addFolderShortcut, getPinnedFiles, logActivity, + changeSSHPermissions, } from "@/ui/main-axios.ts"; import type { SidebarItem } from "./FileManagerSidebar"; @@ -146,6 +148,7 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) { const [createIntent, setCreateIntent] = useState(null); const [editingFile, setEditingFile] = useState(null); + const [permissionsDialogFile, setPermissionsDialogFile] = useState(null); const { selectedFiles, clearSelection, setSelection } = useFileSelection(); @@ -1180,6 +1183,34 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) { setEditingFile(file); } + function handleOpenPermissionsDialog(file: FileItem) { + setPermissionsDialogFile(file); + } + + async function handleSavePermissions(file: FileItem, permissions: string) { + if (!sshSessionId) { + toast.error(t("fileManager.noSSHConnection")); + return; + } + + try { + await changeSSHPermissions( + sshSessionId, + file.path, + permissions, + currentHost?.id, + currentHost?.userId?.toString(), + ); + + toast.success(t("fileManager.permissionsChangedSuccessfully")); + await handleRefreshDirectory(); + } catch (error: unknown) { + console.error("Failed to change permissions:", error); + toast.error(t("fileManager.failedToChangePermissions")); + throw error; + } + } + async function ensureSSHConnection() { if (!sshSessionId || !currentHost || isReconnecting) return; @@ -1968,6 +1999,7 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) { onAddShortcut={handleAddShortcut} isPinned={isPinnedFile} currentPath={currentPath} + onProperties={handleOpenPermissionsDialog} /> @@ -1993,6 +2025,15 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) { }} /> )} + + { + if (!open) setPermissionsDialogFile(null); + }} + onSave={handleSavePermissions} + /> ); } diff --git a/src/ui/desktop/apps/file-manager/components/PermissionsDialog.tsx b/src/ui/desktop/apps/file-manager/components/PermissionsDialog.tsx new file mode 100644 index 00000000..c07f9579 --- /dev/null +++ b/src/ui/desktop/apps/file-manager/components/PermissionsDialog.tsx @@ -0,0 +1,295 @@ +import React, { useState, useEffect } from "react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogFooter, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Input } from "@/components/ui/input"; +import { useTranslation } from "react-i18next"; +import { Shield } from "lucide-react"; + +interface FileItem { + name: string; + type: "file" | "directory" | "link"; + path: string; + permissions?: string; + owner?: string; + group?: string; +} + +interface PermissionsDialogProps { + file: FileItem | null; + open: boolean; + onOpenChange: (open: boolean) => void; + onSave: (file: FileItem, permissions: string) => Promise; +} + +// Parse permissions like "rwxr-xr-x" or "755" to individual bits +const parsePermissions = (perms: string): { owner: number; group: number; other: number } => { + if (!perms) { + return { owner: 0, group: 0, other: 0 }; + } + + // If numeric format like "755" + if (/^\d{3,4}$/.test(perms)) { + const numStr = perms.slice(-3); + return { + owner: parseInt(numStr[0] || "0", 10), + group: parseInt(numStr[1] || "0", 10), + other: parseInt(numStr[2] || "0", 10), + }; + } + + // If symbolic format like "rwxr-xr-x" or "-rwxr-xr-x" + const cleanPerms = perms.replace(/^-/, "").substring(0, 9); + + const calcBits = (str: string): number => { + let value = 0; + if (str[0] === "r") value += 4; + if (str[1] === "w") value += 2; + if (str[2] === "x") value += 1; + return value; + }; + + return { + owner: calcBits(cleanPerms.substring(0, 3)), + group: calcBits(cleanPerms.substring(3, 6)), + other: calcBits(cleanPerms.substring(6, 9)), + }; +}; + +// Convert individual bits to numeric format +const toNumeric = (owner: number, group: number, other: number): string => { + return `${owner}${group}${other}`; +}; + +export function PermissionsDialog({ + file, + open, + onOpenChange, + onSave, +}: PermissionsDialogProps) { + const { t } = useTranslation(); + const [loading, setLoading] = useState(false); + + const initialPerms = parsePermissions(file?.permissions || "644"); + const [ownerRead, setOwnerRead] = useState((initialPerms.owner & 4) !== 0); + const [ownerWrite, setOwnerWrite] = useState((initialPerms.owner & 2) !== 0); + const [ownerExecute, setOwnerExecute] = useState((initialPerms.owner & 1) !== 0); + + const [groupRead, setGroupRead] = useState((initialPerms.group & 4) !== 0); + const [groupWrite, setGroupWrite] = useState((initialPerms.group & 2) !== 0); + const [groupExecute, setGroupExecute] = useState((initialPerms.group & 1) !== 0); + + const [otherRead, setOtherRead] = useState((initialPerms.other & 4) !== 0); + const [otherWrite, setOtherWrite] = useState((initialPerms.other & 2) !== 0); + const [otherExecute, setOtherExecute] = useState((initialPerms.other & 1) !== 0); + + // Reset when file changes + useEffect(() => { + if (file) { + const perms = parsePermissions(file.permissions || "644"); + setOwnerRead((perms.owner & 4) !== 0); + setOwnerWrite((perms.owner & 2) !== 0); + setOwnerExecute((perms.owner & 1) !== 0); + setGroupRead((perms.group & 4) !== 0); + setGroupWrite((perms.group & 2) !== 0); + setGroupExecute((perms.group & 1) !== 0); + setOtherRead((perms.other & 4) !== 0); + setOtherWrite((perms.other & 2) !== 0); + setOtherExecute((perms.other & 1) !== 0); + } + }, [file]); + + const calculateOctal = (): string => { + const owner = (ownerRead ? 4 : 0) + (ownerWrite ? 2 : 0) + (ownerExecute ? 1 : 0); + const group = (groupRead ? 4 : 0) + (groupWrite ? 2 : 0) + (groupExecute ? 1 : 0); + const other = (otherRead ? 4 : 0) + (otherWrite ? 2 : 0) + (otherExecute ? 1 : 0); + return toNumeric(owner, group, other); + }; + + const handleSave = async () => { + if (!file) return; + + setLoading(true); + try { + const permissions = calculateOctal(); + await onSave(file, permissions); + onOpenChange(false); + } catch (error) { + console.error("Failed to update permissions:", error); + } finally { + setLoading(false); + } + }; + + if (!file) return null; + + const octal = calculateOctal(); + + return ( + + + + + + {t("fileManager.changePermissions")} + + + {t("fileManager.changePermissionsDesc")}: {file.name} + + + +
+ {/* Current info */} +
+
+ +

{file.permissions || "644"}

+
+
+ +

{octal}

+
+
+ + {/* Owner permissions */} +
+ +
+
+ setOwnerRead(checked === true)} + /> + +
+
+ setOwnerWrite(checked === true)} + /> + +
+
+ setOwnerExecute(checked === true)} + /> + +
+
+
+ + {/* Group permissions */} +
+ +
+
+ setGroupRead(checked === true)} + /> + +
+
+ setGroupWrite(checked === true)} + /> + +
+
+ setGroupExecute(checked === true)} + /> + +
+
+
+ + {/* Others permissions */} +
+ +
+
+ setOtherRead(checked === true)} + /> + +
+
+ setOtherWrite(checked === true)} + /> + +
+
+ setOtherExecute(checked === true)} + /> + +
+
+
+
+ + + + + +
+
+ ); +} diff --git a/src/ui/main-axios.ts b/src/ui/main-axios.ts index f27aa014..4c147071 100644 --- a/src/ui/main-axios.ts +++ b/src/ui/main-axios.ts @@ -1515,6 +1515,51 @@ export async function moveSSHItem( } } +export async function changeSSHPermissions( + sessionId: string, + path: string, + permissions: string, + hostId?: number, + userId?: string, +): Promise<{ success: boolean; message: string }> { + try { + fileLogger.info("Changing SSH file permissions", { + operation: "change_permissions", + sessionId, + path, + permissions, + hostId, + userId, + }); + + const response = await fileManagerApi.post("/ssh/changePermissions", { + sessionId, + path, + permissions, + hostId, + userId, + }); + + fileLogger.success("SSH file permissions changed successfully", { + operation: "change_permissions", + sessionId, + path, + permissions, + }); + + return response.data; + } catch (error) { + fileLogger.error("Failed to change SSH file permissions", error, { + operation: "change_permissions", + sessionId, + path, + permissions, + }); + handleApiError(error, "change SSH permissions"); + throw error; + } +} + // ============================================================================ // FILE MANAGER DATA // ============================================================================