diff --git a/docker/nginx-https.conf b/docker/nginx-https.conf index c2f90f35..f64e8e4e 100644 --- a/docker/nginx-https.conf +++ b/docker/nginx-https.conf @@ -10,6 +10,9 @@ http { keepalive_timeout 65; client_header_timeout 300s; + set_real_ip_from 127.0.0.1; + real_ip_header X-Forwarded-For; + ssl_protocols TLSv1.2 TLSv1.3; ssl_ciphers ECDHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES128-SHA256:ECDHE-RSA-AES256-SHA384; ssl_prefer_server_ciphers off; @@ -23,7 +26,6 @@ http { return 301 https://$host:${SSL_PORT}$request_uri; } - # HTTPS Server server { listen ${SSL_PORT} ssl; server_name _; @@ -41,7 +43,6 @@ http { index index.html index.htm; } - # Handle missing source map files gracefully location ~* \.map$ { return 404; access_log off; diff --git a/docker/nginx.conf b/docker/nginx.conf index b78418b7..c180c180 100644 --- a/docker/nginx.conf +++ b/docker/nginx.conf @@ -10,6 +10,9 @@ http { keepalive_timeout 65; client_header_timeout 300s; + set_real_ip_from 127.0.0.1; + real_ip_header X-Forwarded-For; + ssl_protocols TLSv1.2 TLSv1.3; ssl_ciphers ECDHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES128-SHA256:ECDHE-RSA-AES256-SHA384; ssl_prefer_server_ciphers off; @@ -29,7 +32,6 @@ http { index index.html index.htm; } - # Handle missing source map files gracefully location ~* \.map$ { return 404; access_log off; diff --git a/package.json b/package.json index d8c1fa4b..3151adaa 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "termix", "private": true, - "version": "1.7.1", + "version": "1.7.2", "description": "A web-based server management platform with SSH terminal, tunneling, and file editing capabilities", "author": "Karmaa", "main": "electron/main.cjs", diff --git a/src/backend/database/db/schema.ts b/src/backend/database/db/schema.ts index bc2bb4d8..eeac5c34 100644 --- a/src/backend/database/db/schema.ts +++ b/src/backend/database/db/schema.ts @@ -46,7 +46,7 @@ export const sshData = sqliteTable("ssh_data", { password: text("password"), key: text("key", { length: 8192 }), - keyPassword: text("key_password"), + key_password: text("key_password"), keyType: text("key_type"), autostartPassword: text("autostart_password"), @@ -142,9 +142,9 @@ export const sshCredentials = sqliteTable("ssh_credentials", { username: text("username").notNull(), password: text("password"), key: text("key", { length: 16384 }), - privateKey: text("private_key", { length: 16384 }), - publicKey: text("public_key", { length: 4096 }), - keyPassword: text("key_password"), + private_key: text("private_key", { length: 16384 }), + public_key: text("public_key", { length: 4096 }), + key_password: text("key_password"), keyType: text("key_type"), detectedKeyType: text("detected_key_type"), usageCount: integer("usage_count").notNull().default(0), diff --git a/src/backend/database/routes/credentials.ts b/src/backend/database/routes/credentials.ts index 9c6dc909..c856322f 100644 --- a/src/backend/database/routes/credentials.ts +++ b/src/backend/database/routes/credentials.ts @@ -174,9 +174,9 @@ router.post( username: username.trim(), password: plainPassword, key: plainKey, - privateKey: keyInfo?.privateKey || plainKey, - publicKey: keyInfo?.publicKey || null, - keyPassword: plainKeyPassword, + private_key: keyInfo?.privateKey || plainKey, + public_key: keyInfo?.publicKey || null, + key_password: plainKeyPassword, keyType: keyType || null, detectedKeyType: keyInfo?.keyType || null, usageCount: 0, @@ -424,13 +424,13 @@ router.put( error: `Invalid SSH key: ${keyInfo.error}`, }); } - updateFields.privateKey = keyInfo.privateKey; - updateFields.publicKey = keyInfo.publicKey; + updateFields.private_key = keyInfo.privateKey; + updateFields.public_key = keyInfo.publicKey; updateFields.detectedKeyType = keyInfo.keyType; } } if (updateData.keyPassword !== undefined) { - updateFields.keyPassword = updateData.keyPassword || null; + updateFields.key_password = updateData.keyPassword || null; } if (Object.keys(updateFields).length === 0) { @@ -537,7 +537,7 @@ router.delete( credentialId: null, password: null, key: null, - keyPassword: null, + key_password: null, authType: "password", }) .where( @@ -633,7 +633,7 @@ router.post( authType: credential.auth_type || credential.authType, password: null, key: null, - keyPassword: null, + key_password: null, keyType: null, updatedAt: new Date().toISOString(), }) diff --git a/src/backend/database/routes/ssh.ts b/src/backend/database/routes/ssh.ts index 3cb76e67..9304a647 100644 --- a/src/backend/database/routes/ssh.ts +++ b/src/backend/database/routes/ssh.ts @@ -91,7 +91,7 @@ router.get("/db/host/internal", async (req: Request, res: Response) => { username: host.username, password: host.autostartPassword, key: host.autostartKey, - keyPassword: host.autostartKeyPassword, + key_password: host.autostartKeyPassword, autostartPassword: host.autostartPassword, autostartKey: host.autostartKey, autostartKeyPassword: host.autostartKeyPassword, @@ -151,7 +151,7 @@ router.get("/db/host/internal/all", async (req: Request, res: Response) => { username: host.username, password: host.autostartPassword || host.password, key: host.autostartKey || host.key, - keyPassword: host.autostartKeyPassword || host.keyPassword, + key_password: host.autostartKeyPassword || host.key_password, autostartPassword: host.autostartPassword, autostartKey: host.autostartKey, autostartKeyPassword: host.autostartKeyPassword, @@ -226,7 +226,7 @@ router.post( authType, credentialId, key, - keyPassword, + key_password, keyType, pin, enableTerminal, @@ -274,17 +274,17 @@ router.post( if (effectiveAuthType === "password") { sshDataObj.password = password || null; sshDataObj.key = null; - sshDataObj.keyPassword = null; + sshDataObj.key_password = null; sshDataObj.keyType = null; } else if (effectiveAuthType === "key") { sshDataObj.key = key || null; - sshDataObj.keyPassword = keyPassword || null; + sshDataObj.key_password = key_password || null; sshDataObj.keyType = keyType; sshDataObj.password = null; } else { sshDataObj.password = null; sshDataObj.key = null; - sshDataObj.keyPassword = null; + sshDataObj.key_password = null; sshDataObj.keyType = null; } @@ -407,7 +407,7 @@ router.put( authType, credentialId, key, - keyPassword, + key_password, keyType, pin, enableTerminal, @@ -458,14 +458,14 @@ router.put( sshDataObj.password = password; } sshDataObj.key = null; - sshDataObj.keyPassword = null; + sshDataObj.key_password = null; sshDataObj.keyType = null; } else if (effectiveAuthType === "key") { if (key) { sshDataObj.key = key; } - if (keyPassword !== undefined) { - sshDataObj.keyPassword = keyPassword || null; + if (key_password !== undefined) { + sshDataObj.key_password = key_password || null; } if (keyType) { sshDataObj.keyType = keyType; @@ -474,7 +474,7 @@ router.put( } else { sshDataObj.password = null; sshDataObj.key = null; - sshDataObj.keyPassword = null; + sshDataObj.key_password = null; sshDataObj.keyType = null; } @@ -711,7 +711,7 @@ router.get( authType: resolvedHost.authType, password: resolvedHost.password || null, key: resolvedHost.key || null, - keyPassword: resolvedHost.keyPassword || null, + key_password: resolvedHost.key_password || null, keyType: resolvedHost.keyType || null, folder: resolvedHost.folder, tags: @@ -1234,7 +1234,7 @@ async function resolveHostCredentials(host: any): Promise { authType: credential.auth_type || credential.authType, password: credential.password, key: credential.key, - keyPassword: credential.key_password || credential.keyPassword, + key_password: credential.key_password || credential.key_password, keyType: credential.key_type || credential.keyType, }; } @@ -1404,8 +1404,8 @@ router.post( credentialId: hostData.authType === "credential" ? hostData.credentialId : null, key: hostData.authType === "key" ? hostData.key : null, - keyPassword: - hostData.authType === "key" ? hostData.keyPassword : null, + key_password: + hostData.authType === "key" ? hostData.key_password : null, keyType: hostData.authType === "key" ? hostData.keyType || "auto" : null, pin: hostData.pin || false, @@ -1540,7 +1540,7 @@ router.post( ...tunnel, endpointPassword: decryptedEndpoint.password || null, endpointKey: decryptedEndpoint.key || null, - endpointKeyPassword: decryptedEndpoint.keyPassword || null, + endpointKeyPassword: decryptedEndpoint.key_password || null, endpointAuthType: endpointHost.authType, }; } @@ -1563,7 +1563,7 @@ router.post( .set({ autostartPassword: decryptedConfig.password || null, autostartKey: decryptedConfig.key || null, - autostartKeyPassword: decryptedConfig.keyPassword || null, + autostartKeyPassword: decryptedConfig.key_password || null, tunnelConnections: updatedTunnelConnections, }) .where(eq(sshData.id, sshConfigId)); diff --git a/src/backend/utils/lazy-field-encryption.ts b/src/backend/utils/lazy-field-encryption.ts index 8eae9193..efe5ea75 100644 --- a/src/backend/utils/lazy-field-encryption.ts +++ b/src/backend/utils/lazy-field-encryption.ts @@ -6,6 +6,20 @@ export class LazyFieldEncryption { key_password: "keyPassword", private_key: "privateKey", public_key: "publicKey", + password_hash: "passwordHash", + client_secret: "clientSecret", + totp_secret: "totpSecret", + totp_backup_codes: "totpBackupCodes", + oidc_identifier: "oidcIdentifier", + + keyPassword: "key_password", + privateKey: "private_key", + publicKey: "public_key", + passwordHash: "password_hash", + clientSecret: "client_secret", + totpSecret: "totp_secret", + totpBackupCodes: "totp_backup_codes", + oidcIdentifier: "oidc_identifier", }; static isPlaintextField(value: string): boolean { diff --git a/src/backend/utils/user-crypto.ts b/src/backend/utils/user-crypto.ts index 4164ece4..73a2e39f 100644 --- a/src/backend/utils/user-crypto.ts +++ b/src/backend/utils/user-crypto.ts @@ -70,7 +70,36 @@ class UserCrypto { } async setupOIDCUserEncryption(userId: string): Promise { - const DEK = crypto.randomBytes(UserCrypto.DEK_LENGTH); + const existingEncryptedDEK = await this.getEncryptedDEK(userId); + + let DEK: Buffer; + + if (existingEncryptedDEK) { + const systemKey = this.deriveOIDCSystemKey(userId); + DEK = this.decryptDEK(existingEncryptedDEK, systemKey); + systemKey.fill(0); + } else { + DEK = crypto.randomBytes(UserCrypto.DEK_LENGTH); + const systemKey = this.deriveOIDCSystemKey(userId); + + try { + const encryptedDEK = this.encryptDEK(DEK, systemKey); + await this.storeEncryptedDEK(userId, encryptedDEK); + + const storedEncryptedDEK = await this.getEncryptedDEK(userId); + if ( + storedEncryptedDEK && + storedEncryptedDEK.data !== encryptedDEK.data + ) { + DEK.fill(0); + DEK = this.decryptDEK(storedEncryptedDEK, systemKey); + } else if (!storedEncryptedDEK) { + throw new Error("Failed to store and retrieve user encryption key."); + } + } finally { + systemKey.fill(0); + } + } const now = Date.now(); this.userSessions.set(userId, { @@ -134,20 +163,14 @@ class UserCrypto { async authenticateOIDCUser(userId: string): Promise { try { - const kekSalt = await this.getKEKSalt(userId); - if (!kekSalt) { + const encryptedDEK = await this.getEncryptedDEK(userId); + + if (!encryptedDEK) { await this.setupOIDCUserEncryption(userId); return true; } const systemKey = this.deriveOIDCSystemKey(userId); - const encryptedDEK = await this.getEncryptedDEK(userId); - if (!encryptedDEK) { - systemKey.fill(0); - await this.setupOIDCUserEncryption(userId); - return true; - } - const DEK = this.decryptDEK(encryptedDEK, systemKey); systemKey.fill(0); diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 0e6d735e..d3aff79d 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -844,8 +844,13 @@ "selectServerToEdit": "Select a server from the sidebar to start editing files", "fileOperations": "File Operations", "confirmDeleteMessage": "Are you sure you want to delete {{name}}?", + "confirmDeleteSingleItem": "Are you sure you want to permanently delete \"{{name}}\"?", + "confirmDeleteMultipleItems": "Are you sure you want to permanently delete {{count}} items?", + "confirmDeleteMultipleItemsWithFolders": "Are you sure you want to permanently delete {{count}} items? This includes folders and their contents.", + "confirmDeleteFolder": "Are you sure you want to permanently delete the folder \"{{name}}\" and all its contents?", "deleteDirectoryWarning": "This will delete the folder and all its contents.", "actionCannotBeUndone": "This action cannot be undone.", + "permanentDeleteWarning": "This action cannot be undone. The item(s) will be permanently deleted from the server.", "recent": "Recent", "pinned": "Pinned", "folderShortcuts": "Folder Shortcuts", diff --git a/src/locales/zh/translation.json b/src/locales/zh/translation.json index 62069e11..0cd92395 100644 --- a/src/locales/zh/translation.json +++ b/src/locales/zh/translation.json @@ -852,8 +852,13 @@ "selectServerToEdit": "从侧边栏选择服务器以开始编辑文件", "fileOperations": "文件操作", "confirmDeleteMessage": "确定要删除 {{name}} 吗?", + "confirmDeleteSingleItem": "确定要永久删除 \"{{name}}\" 吗?", + "confirmDeleteMultipleItems": "确定要永久删除 {{count}} 个项目吗?", + "confirmDeleteMultipleItemsWithFolders": "确定要永久删除 {{count}} 个项目吗?这包括文件夹及其内容。", + "confirmDeleteFolder": "确定要永久删除文件夹 \"{{name}}\" 及其所有内容吗?", "deleteDirectoryWarning": "这将删除文件夹及其所有内容。", "actionCannotBeUndone": "此操作无法撤销。", + "permanentDeleteWarning": "此操作无法撤销。项目将从服务器永久删除。", "dragSystemFilesToUpload": "拖拽系统文件到此处上传", "dragFilesToWindowToDownload": "拖拽文件到窗口外下载", "openTerminalHere": "在此处打开终端", diff --git a/src/ui/Desktop/Apps/File Manager/FileManager.tsx b/src/ui/Desktop/Apps/File Manager/FileManager.tsx index 0f05c589..ea991003 100644 --- a/src/ui/Desktop/Apps/File Manager/FileManager.tsx +++ b/src/ui/Desktop/Apps/File Manager/FileManager.tsx @@ -9,6 +9,7 @@ import { FileWindow } from "./components/FileWindow"; import { DiffWindow } from "./components/DiffWindow"; import { useDragToDesktop } from "../../../hooks/useDragToDesktop"; import { useDragToSystemDesktop } from "../../../hooks/useDragToSystemDesktop"; +import { useConfirmation } from "@/hooks/use-confirmation.ts"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { toast } from "sonner"; @@ -82,6 +83,7 @@ function formatFileSize(bytes?: number): string { function FileManagerContent({ initialHost, onClose }: FileManagerProps) { const { openWindow } = useWindowManager(); const { t } = useTranslation(); + const { confirmWithToast } = useConfirmation(); const [currentHost, setCurrentHost] = useState( initialHost || null, @@ -587,54 +589,85 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) { async function handleDeleteFiles(files: FileItem[]) { if (!sshSessionId || files.length === 0) return; - try { - await ensureSSHConnection(); - - for (const file of files) { - await deleteSSHItem( - sshSessionId, - file.path, - file.type === "directory", - currentHost?.id, - currentHost?.userId?.toString(), - ); - } - - const deletedFiles = files.map((file) => ({ - path: file.path, - name: file.name, - })); - - const undoAction: UndoAction = { - type: "delete", - description: t("fileManager.deletedItems", { count: files.length }), - data: { - operation: "cut", - deletedFiles, - targetDirectory: currentPath, - }, - timestamp: Date.now(), - }; - setUndoHistory((prev) => [...prev.slice(-9), undoAction]); - - toast.success( - t("fileManager.itemsDeletedSuccessfully", { count: files.length }), - ); - handleRefreshDirectory(); - clearSelection(); - } catch (error: any) { - if ( - error.message?.includes("connection") || - error.message?.includes("established") - ) { - toast.error( - `SSH connection failed. Please check your connection to ${currentHost?.name} (${currentHost?.ip}:${currentHost?.port})`, - ); + let confirmMessage: string; + if (files.length === 1) { + const file = files[0]; + if (file.type === "directory") { + confirmMessage = t("fileManager.confirmDeleteFolder", { + name: file.name, + }); } else { - toast.error(t("fileManager.failedToDeleteItems")); + confirmMessage = t("fileManager.confirmDeleteSingleItem", { + name: file.name, + }); } - console.error("Delete failed:", error); + } else { + const hasDirectory = files.some((file) => file.type === "directory"); + const translationKey = hasDirectory + ? "fileManager.confirmDeleteMultipleItemsWithFolders" + : "fileManager.confirmDeleteMultipleItems"; + + confirmMessage = t(translationKey, { + count: files.length, + }); } + + const fullMessage = `${confirmMessage}\n\n${t("fileManager.permanentDeleteWarning")}`; + + confirmWithToast( + fullMessage, + async () => { + try { + await ensureSSHConnection(); + + for (const file of files) { + await deleteSSHItem( + sshSessionId, + file.path, + file.type === "directory", + currentHost?.id, + currentHost?.userId?.toString(), + ); + } + + const deletedFiles = files.map((file) => ({ + path: file.path, + name: file.name, + })); + + const undoAction: UndoAction = { + type: "delete", + description: t("fileManager.deletedItems", { count: files.length }), + data: { + operation: "cut", + deletedFiles, + targetDirectory: currentPath, + }, + timestamp: Date.now(), + }; + setUndoHistory((prev) => [...prev.slice(-9), undoAction]); + + toast.success( + t("fileManager.itemsDeletedSuccessfully", { count: files.length }), + ); + handleRefreshDirectory(); + clearSelection(); + } catch (error: any) { + if ( + error.message?.includes("connection") || + error.message?.includes("established") + ) { + toast.error( + `SSH connection failed. Please check your connection to ${currentHost?.name} (${currentHost?.ip}:${currentHost?.port})`, + ); + } else { + toast.error(t("fileManager.failedToDeleteItems")); + } + console.error("Delete failed:", error); + } + }, + "destructive", + ); } function handleCreateNewFolder() { diff --git a/src/ui/Desktop/Apps/Host Manager/HostManagerEditor.tsx b/src/ui/Desktop/Apps/Host Manager/HostManagerEditor.tsx index 08a41724..fe0a2c0b 100644 --- a/src/ui/Desktop/Apps/Host Manager/HostManagerEditor.tsx +++ b/src/ui/Desktop/Apps/Host Manager/HostManagerEditor.tsx @@ -210,7 +210,18 @@ export function HostManagerEditor({ defaultPath: z.string().optional(), }) .superRefine((data, ctx) => { - if (data.authType === "key") { + if (data.authType === "password") { + if ( + !data.password || + (typeof data.password === "string" && data.password.trim() === "") + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: t("hosts.passwordRequired"), + path: ["password"], + }); + } + } else if (data.authType === "key") { if ( !data.key || (typeof data.key === "string" && data.key.trim() === "") diff --git a/src/ui/Desktop/DesktopApp.tsx b/src/ui/Desktop/DesktopApp.tsx index 3c1487f6..add9611d 100644 --- a/src/ui/Desktop/DesktopApp.tsx +++ b/src/ui/Desktop/DesktopApp.tsx @@ -24,7 +24,10 @@ function AppContent() { const [isAdmin, setIsAdmin] = useState(false); const [authLoading, setAuthLoading] = useState(true); const [showVersionCheck, setShowVersionCheck] = useState(true); - const [isTopbarOpen, setIsTopbarOpen] = useState(true); + const [isTopbarOpen, setIsTopbarOpen] = useState(() => { + const saved = localStorage.getItem("topNavbarOpen"); + return saved !== null ? JSON.parse(saved) : true; + }); const { currentTab, tabs } = useTabs(); useEffect(() => { @@ -64,6 +67,10 @@ function AppContent() { return () => window.removeEventListener("storage", handleStorageChange); }, []); + useEffect(() => { + localStorage.setItem("topNavbarOpen", JSON.stringify(isTopbarOpen)); + }, [isTopbarOpen]); + const handleSelectView = (nextView: string) => { setMountedViews((prev) => { if (prev.has(nextView)) return prev; diff --git a/src/ui/Desktop/Navigation/LeftSidebar.tsx b/src/ui/Desktop/Navigation/LeftSidebar.tsx index 2f7ef7ee..72ae3bef 100644 --- a/src/ui/Desktop/Navigation/LeftSidebar.tsx +++ b/src/ui/Desktop/Navigation/LeftSidebar.tsx @@ -101,7 +101,10 @@ export function LeftSidebar({ const [deleteLoading, setDeleteLoading] = React.useState(false); const [deleteError, setDeleteError] = React.useState(null); - const [isSidebarOpen, setIsSidebarOpen] = useState(true); + const [isSidebarOpen, setIsSidebarOpen] = useState(() => { + const saved = localStorage.getItem("leftSidebarOpen"); + return saved !== null ? JSON.parse(saved) : true; + }); const { tabs: tabList, @@ -181,7 +184,6 @@ export function LeftSidebar({ newHost.key !== existingHost.key || newHost.keyPassword !== existingHost.keyPassword || newHost.keyType !== existingHost.keyType || - newHost.credentialId !== existingHost.credentialId || newHost.defaultPath !== existingHost.defaultPath || JSON.stringify(newHost.tags) !== JSON.stringify(existingHost.tags) || @@ -247,6 +249,10 @@ export function LeftSidebar({ return () => clearTimeout(handler); }, [search]); + React.useEffect(() => { + localStorage.setItem("leftSidebarOpen", JSON.stringify(isSidebarOpen)); + }, [isSidebarOpen]); + const filteredHosts = React.useMemo(() => { if (!debouncedSearch.trim()) return hosts; const q = debouncedSearch.trim().toLowerCase();