From 694ea8752392b0fbe8245b6989e883620e229bc5 Mon Sep 17 00:00:00 2001 From: LukeGus Date: Fri, 3 Oct 2025 00:01:36 -0500 Subject: [PATCH] v.1.7.1 --- .github/pull_request_template.md | 18 +- CODE_OF_CONDUCT.md | 22 +-- electron-builder.json | 22 ++- electron/main.cjs | 6 + package.json | 4 +- src/backend/database/routes/credentials.ts | 61 ++++--- src/backend/database/routes/ssh.ts | 105 +++++++++-- src/backend/database/routes/users.ts | 53 ++++++ src/backend/ssh/file-manager.ts | 7 +- src/backend/ssh/server-stats.ts | 33 ++-- src/backend/ssh/terminal.ts | 37 +++- src/backend/ssh/tunnel.ts | 27 +-- src/backend/utils/data-crypto.ts | 162 ++++++++++++++++- src/backend/utils/field-crypto.ts | 8 +- src/backend/utils/lazy-field-encryption.ts | 172 ++++++++++++++++-- src/locales/en/translation.json | 14 +- src/locales/zh/translation.json | 14 +- .../File Manager/components/DiffViewer.tsx | 21 ++- .../components/DraggableWindow.tsx | 7 + .../File Manager/components/FileViewer.tsx | 11 -- .../components/TerminalWindow.tsx | 24 +++ .../Apps/Host Manager/HostManagerViewer.tsx | 65 +++---- src/ui/Desktop/Apps/Server/Server.tsx | 7 +- src/ui/Desktop/Apps/Terminal/Terminal.tsx | 76 +++++++- src/ui/Mobile/Apps/Terminal/Terminal.tsx | 75 +++++++- src/ui/main-axios.ts | 12 ++ 26 files changed, 877 insertions(+), 186 deletions(-) diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 4eeedd56..782f3d84 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,23 +1,31 @@ # Overview + _Short summary of what this PR does_ + - [ ] Added: ... - [ ] Updated: ... - [ ] Removed: ... - [ ] Fixed: ... # Changes Made + _Detailed explanation of changes (if needed)_ + - ... # Related Issues + _Link any issues this PR addresses_ -- Closes #ISSUE_NUMBER -- Related to #ISSUE_NUMBER + +- Closes #ISSUE_NUMBER +- Related to #ISSUE_NUMBER # Screenshots / Demos + _(Optional: add before/after screenshots, GIFs, or console output)_ # Checklist -- [ ] Code follows project style guidelines -- [ ] Supports mobile and desktop UI/app (if applicable) -- [ ] I have read [Contributing.md](https://github.com/LukeGus/Termix/blob/main/CONTRIBUTING.md) + +- [ ] Code follows project style guidelines +- [ ] Supports mobile and desktop UI/app (if applicable) +- [ ] I have read [Contributing.md](https://github.com/LukeGus/Termix/blob/main/CONTRIBUTING.md) diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index e7ebc8dd..6975ffb1 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -17,23 +17,23 @@ diverse, inclusive, and healthy community. Examples of behavior that contributes to a positive environment for our community include: -* Demonstrating empathy and kindness toward other people -* Being respectful of differing opinions, viewpoints, and experiences -* Giving and gracefully accepting constructive feedback -* Accepting responsibility and apologizing to those affected by our mistakes, +- Demonstrating empathy and kindness toward other people +- Being respectful of differing opinions, viewpoints, and experiences +- Giving and gracefully accepting constructive feedback +- Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience -* Focusing on what is best not just for us as individuals, but for the +- Focusing on what is best not just for us as individuals, but for the overall community Examples of unacceptable behavior include: -* The use of sexualized language or imagery, and sexual attention or +- The use of sexualized language or imagery, and sexual attention or advances of any kind -* Trolling, insulting or derogatory comments, and personal or political attacks -* Public or private harassment -* Publishing others' private information, such as a physical or email +- Trolling, insulting or derogatory comments, and personal or political attacks +- Public or private harassment +- Publishing others' private information, such as a physical or email address, without their explicit permission -* Other conduct which could reasonably be considered inappropriate in a +- Other conduct which could reasonably be considered inappropriate in a professional setting ## Enforcement Responsibilities @@ -106,7 +106,7 @@ Violating these terms may lead to a permanent ban. ### 4. Permanent Ban **Community Impact**: Demonstrating a pattern of violation of community -standards, including sustained inappropriate behavior, harassment of an +standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals. **Consequence**: A permanent ban from any sort of public interaction within diff --git a/electron-builder.json b/electron-builder.json index 21bdb711..9fb6d36b 100644 --- a/electron-builder.json +++ b/electron-builder.json @@ -37,8 +37,26 @@ "uninstallDisplayName": "Termix" }, "linux": { - "target": "AppImage", + "target": [ + { + "target": "AppImage", + "arch": ["x64"] + }, + { + "target": "tar.gz", + "arch": ["x64"] + } + ], "icon": "public/icon.png", - "category": "Development" + "category": "Development", + "executableName": "termix", + "desktop": { + "entry": { + "Name": "Termix", + "Comment": "A web-based server management platform", + "Keywords": "terminal;ssh;server;management;", + "StartupWMClass": "termix" + } + } } } diff --git a/electron/main.cjs b/electron/main.cjs index 7a7ba5bc..8af9451b 100644 --- a/electron/main.cjs +++ b/electron/main.cjs @@ -8,6 +8,12 @@ app.commandLine.appendSwitch("--ignore-ssl-errors"); app.commandLine.appendSwitch("--ignore-certificate-errors-spki-list"); app.commandLine.appendSwitch("--enable-features=NetworkService"); +if (process.platform === "linux") { + app.commandLine.appendSwitch("--no-sandbox"); + app.commandLine.appendSwitch("--disable-setuid-sandbox"); + app.commandLine.appendSwitch("--disable-dev-shm-usage"); +} + let mainWindow = null; const isDev = process.env.NODE_ENV === "development" || !app.isPackaged; diff --git a/package.json b/package.json index 1b587c96..d8c1fa4b 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "termix", "private": true, - "version": "1.7.0", + "version": "1.7.1", "description": "A web-based server management platform with SSH terminal, tunneling, and file editing capabilities", "author": "Karmaa", "main": "electron/main.cjs", @@ -17,6 +17,8 @@ "build:win-portable": "npm run build && electron-builder --win --dir", "build:win-installer": "npm run build && electron-builder --win --publish=never", "build:linux-portable": "npm run build && electron-builder --linux --dir", + "build:linux-appimage": "npm run build && electron-builder --linux AppImage", + "build:linux-targz": "npm run build && electron-builder --linux tar.gz", "test:encryption": "tsc -p tsconfig.node.json && node ./dist/backend/backend/utils/encryption-test.js", "migrate:encryption": "tsc -p tsconfig.node.json && node ./dist/backend/backend/utils/encryption-migration.js" }, diff --git a/src/backend/database/routes/credentials.ts b/src/backend/database/routes/credentials.ts index ac022f87..9c6dc909 100644 --- a/src/backend/database/routes/credentials.ts +++ b/src/backend/database/routes/credentials.ts @@ -333,14 +333,14 @@ router.get( if (credential.key) { (output as any).key = credential.key; } - if (credential.privateKey) { - (output as any).privateKey = credential.privateKey; + if (credential.private_key) { + (output as any).privateKey = credential.private_key; } - if (credential.publicKey) { - (output as any).publicKey = credential.publicKey; + if (credential.public_key) { + (output as any).publicKey = credential.public_key; } - if (credential.keyPassword) { - (output as any).keyPassword = credential.keyPassword; + if (credential.key_password) { + (output as any).keyPassword = credential.key_password; } res.json(output); @@ -605,15 +605,19 @@ router.post( } try { - const credentials = await db - .select() - .from(sshCredentials) - .where( - and( - eq(sshCredentials.id, parseInt(credentialId)), - eq(sshCredentials.userId, userId), + const credentials = await SimpleDBOps.select( + db + .select() + .from(sshCredentials) + .where( + and( + eq(sshCredentials.id, parseInt(credentialId)), + eq(sshCredentials.userId, userId), + ), ), - ); + "ssh_credentials", + userId, + ); if (credentials.length === 0) { return res.status(404).json({ error: "Credential not found" }); @@ -626,7 +630,7 @@ router.post( .set({ credentialId: parseInt(credentialId), username: credential.username, - authType: credential.authType, + authType: credential.auth_type || credential.authType, password: null, key: null, keyPassword: null, @@ -715,15 +719,15 @@ function formatCredentialOutput(credential: any): any { ? credential.tags.split(",").filter(Boolean) : [] : [], - authType: credential.authType, + authType: credential.authType || credential.auth_type, username: credential.username, - publicKey: credential.publicKey, - keyType: credential.keyType, - detectedKeyType: credential.detectedKeyType, - usageCount: credential.usageCount || 0, - lastUsed: credential.lastUsed, - createdAt: credential.createdAt, - updatedAt: credential.updatedAt, + publicKey: credential.public_key || credential.publicKey, + keyType: credential.key_type || credential.keyType, + detectedKeyType: credential.detected_key_type || credential.detectedKeyType, + usageCount: credential.usage_count || credential.usageCount || 0, + lastUsed: credential.last_used || credential.lastUsed, + createdAt: credential.created_at || credential.createdAt, + updatedAt: credential.updated_at || credential.updatedAt, }; } @@ -1551,14 +1555,15 @@ router.post( if (hostCredential && hostCredential.length > 0) { const cred = hostCredential[0]; - hostConfig.authType = cred.authType; + hostConfig.authType = cred.auth_type || cred.authType; hostConfig.username = cred.username; - if (cred.authType === "password") { + if ((cred.auth_type || cred.authType) === "password") { hostConfig.password = cred.password; - } else if (cred.authType === "key") { - hostConfig.privateKey = cred.privateKey || cred.key; - hostConfig.keyPassword = cred.keyPassword; + } else if ((cred.auth_type || cred.authType) === "key") { + hostConfig.privateKey = + cred.private_key || cred.privateKey || cred.key; + hostConfig.keyPassword = cred.key_password || cred.keyPassword; } } else { return res.status(400).json({ diff --git a/src/backend/database/routes/ssh.ts b/src/backend/database/routes/ssh.ts index 6420aa74..3cb76e67 100644 --- a/src/backend/database/routes/ssh.ts +++ b/src/backend/database/routes/ssh.ts @@ -472,7 +472,6 @@ router.put( } sshDataObj.password = null; } else { - // For credential auth sshDataObj.password = null; sshDataObj.key = null; sshDataObj.keyPassword = null; @@ -670,6 +669,83 @@ router.get( }, ); +// Route: Export SSH host with decrypted credentials (requires data access) +// GET /ssh/db/host/:id/export +router.get( + "/db/host/:id/export", + authenticateJWT, + requireDataAccess, + async (req: Request, res: Response) => { + const hostId = req.params.id; + const userId = (req as any).userId; + + if (!isNonEmptyString(userId) || !hostId) { + return res.status(400).json({ error: "Invalid userId or hostId" }); + } + + try { + const hosts = await SimpleDBOps.select( + db + .select() + .from(sshData) + .where( + and(eq(sshData.id, Number(hostId)), eq(sshData.userId, userId)), + ), + "ssh_data", + userId, + ); + + if (hosts.length === 0) { + return res.status(404).json({ error: "SSH host not found" }); + } + + const host = hosts[0]; + + const resolvedHost = (await resolveHostCredentials(host)) || host; + + const exportData = { + name: resolvedHost.name, + ip: resolvedHost.ip, + port: resolvedHost.port, + username: resolvedHost.username, + authType: resolvedHost.authType, + password: resolvedHost.password || null, + key: resolvedHost.key || null, + keyPassword: resolvedHost.keyPassword || null, + keyType: resolvedHost.keyType || null, + folder: resolvedHost.folder, + tags: + typeof resolvedHost.tags === "string" + ? resolvedHost.tags.split(",").filter(Boolean) + : resolvedHost.tags || [], + pin: !!resolvedHost.pin, + enableTerminal: !!resolvedHost.enableTerminal, + enableTunnel: !!resolvedHost.enableTunnel, + enableFileManager: !!resolvedHost.enableFileManager, + defaultPath: resolvedHost.defaultPath, + tunnelConnections: resolvedHost.tunnelConnections + ? JSON.parse(resolvedHost.tunnelConnections) + : [], + }; + + sshLogger.success("Host exported with decrypted credentials", { + operation: "host_export", + hostId: parseInt(hostId), + userId, + }); + + res.json(exportData); + } catch (err) { + sshLogger.error("Failed to export SSH host", err, { + operation: "host_export", + hostId: parseInt(hostId), + userId, + }); + res.status(500).json({ error: "Failed to export SSH host" }); + } + }, +); + // Route: Delete SSH host by id (requires JWT) // DELETE /ssh/host/:id router.delete( @@ -1136,26 +1212,30 @@ router.delete( async function resolveHostCredentials(host: any): Promise { try { if (host.credentialId && host.userId) { - const credentials = await db - .select() - .from(sshCredentials) - .where( - and( - eq(sshCredentials.id, host.credentialId), - eq(sshCredentials.userId, host.userId), + const credentials = await SimpleDBOps.select( + db + .select() + .from(sshCredentials) + .where( + and( + eq(sshCredentials.id, host.credentialId), + eq(sshCredentials.userId, host.userId), + ), ), - ); + "ssh_credentials", + host.userId, + ); if (credentials.length > 0) { const credential = credentials[0]; return { ...host, username: credential.username, - authType: credential.authType, + authType: credential.auth_type || credential.authType, password: credential.password, key: credential.key, - keyPassword: credential.keyPassword, - keyType: credential.keyType, + keyPassword: credential.key_password || credential.keyPassword, + keyType: credential.key_type || credential.keyType, }; } } @@ -1214,7 +1294,6 @@ router.put( ) .returning(); - // Trigger database save after folder rename DatabaseSaveTrigger.triggerSave("folder_rename"); res.json({ diff --git a/src/backend/database/routes/users.ts b/src/backend/database/routes/users.ts index 78f6578d..3c2303cc 100644 --- a/src/backend/database/routes/users.ts +++ b/src/backend/database/routes/users.ts @@ -1317,6 +1317,43 @@ router.post("/complete-reset", async (req, res) => { .set({ password_hash }) .where(eq(users.username, username)); + try { + await authManager.registerUser(userId, newPassword); + authManager.logoutUser(userId); + + await db + .update(users) + .set({ + totp_enabled: false, + totp_secret: null, + totp_backup_codes: null, + }) + .where(eq(users.id, userId)); + + authLogger.warn( + `Password reset completed for user: ${username}. Existing encrypted data is now inaccessible and will need to be re-entered.`, + { + operation: "password_reset_data_inaccessible", + userId, + username, + }, + ); + } catch (encryptionError) { + authLogger.error( + "Failed to re-encrypt user data after password reset", + encryptionError, + { + operation: "password_reset_encryption_failed", + userId, + username, + }, + ); + return res.status(500).json({ + error: + "Password reset completed but user data encryption failed. Please contact administrator.", + }); + } + authLogger.success(`Password successfully reset for user: ${username}`); db.$client @@ -1495,6 +1532,22 @@ router.post("/totp/verify-login", async (req, res) => { "totp_secret", ); + if (!totpSecret) { + await db + .update(users) + .set({ + totp_enabled: false, + totp_secret: null, + totp_backup_codes: null, + }) + .where(eq(users.id, userRecord.id)); + + return res.status(400).json({ + error: + "TOTP has been disabled due to password reset. Please set up TOTP again.", + }); + } + const verified = speakeasy.totp.verify({ secret: totpSecret, encoding: "base32", diff --git a/src/backend/ssh/file-manager.ts b/src/backend/ssh/file-manager.ts index 4453adb7..99ce1e95 100644 --- a/src/backend/ssh/file-manager.ts +++ b/src/backend/ssh/file-manager.ts @@ -202,9 +202,10 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => { const credential = credentials[0]; resolvedCredentials = { password: credential.password, - sshKey: credential.privateKey || credential.key, - keyPassword: credential.keyPassword, - authType: credential.authType, + sshKey: + credential.private_key || credential.privateKey || credential.key, + keyPassword: credential.key_password || credential.keyPassword, + authType: credential.auth_type || credential.authType, }; } else { fileLogger.warn(`No credentials found for host ${hostId}`, { diff --git a/src/backend/ssh/server-stats.ts b/src/backend/ssh/server-stats.ts index c59c9ff9..f580bb96 100644 --- a/src/backend/ssh/server-stats.ts +++ b/src/backend/ssh/server-stats.ts @@ -280,10 +280,8 @@ const app = express(); app.use( cors({ origin: (origin, callback) => { - // Allow requests with no origin (like mobile apps or curl requests) if (!origin) return callback(null, true); - // Allow localhost and 127.0.0.1 for development const allowedOrigins = [ "http://localhost:5173", "http://localhost:3000", @@ -291,22 +289,18 @@ app.use( "http://127.0.0.1:3000", ]; - // Allow any HTTPS origin (production deployments) if (origin.startsWith("https://")) { return callback(null, true); } - // Allow any HTTP origin for self-hosted scenarios if (origin.startsWith("http://")) { return callback(null, true); } - // Check against allowed development origins if (allowedOrigins.includes(origin)) { return callback(null, true); } - // Reject other origins callback(new Error("Not allowed by CORS")); }, credentials: true, @@ -322,7 +316,6 @@ app.use( app.use(cookieParser()); app.use(express.json({ limit: "1mb" })); -// Add authentication middleware - Linus principle: eliminate special cases app.use(authManager.createAuthMiddleware()); const hostStatuses: Map = new Map(); @@ -363,7 +356,6 @@ async function fetchHostById( userId: string, ): Promise { try { - // Check if user data is unlocked before attempting to fetch if (!SimpleDBOps.isUserDataUnlocked(userId)) { statsLogger.debug("User data locked - cannot fetch host", { operation: "fetchHostById_data_locked", @@ -446,7 +438,7 @@ async function resolveHostCredentials( const credential = credentials[0]; baseHost.credentialId = credential.id; baseHost.username = credential.username; - baseHost.authType = credential.authType; + baseHost.authType = credential.auth_type || credential.authType; if (credential.password) { baseHost.password = credential.password; @@ -454,11 +446,12 @@ async function resolveHostCredentials( if (credential.key) { baseHost.key = credential.key; } - if (credential.keyPassword) { - baseHost.keyPassword = credential.keyPassword; + if (credential.key_password || credential.keyPassword) { + baseHost.keyPassword = + credential.key_password || credential.keyPassword; } - if (credential.keyType) { - baseHost.keyType = credential.keyType; + if (credential.key_type || credential.keyType) { + baseHost.keyType = credential.key_type || credential.keyType; } } else { addLegacyCredentials(baseHost, host); @@ -750,6 +743,7 @@ async function collectMetrics(host: SSHHostWithCredentials): Promise<{ let diskPercent: number | null = null; let usedHuman: string | null = null; let totalHuman: string | null = null; + let availableHuman: string | null = null; try { const [diskOutHuman, diskOutBytes] = await Promise.all([ execCommand(client, "df -h -P / | tail -n +2"), @@ -773,6 +767,7 @@ async function collectMetrics(host: SSHHostWithCredentials): Promise<{ if (humanParts.length >= 6 && bytesParts.length >= 6) { totalHuman = humanParts[1] || null; usedHuman = humanParts[2] || null; + availableHuman = humanParts[3] || null; const totalBytes = Number(bytesParts[1]); const usedBytes = Number(bytesParts[2]); @@ -796,6 +791,7 @@ async function collectMetrics(host: SSHHostWithCredentials): Promise<{ diskPercent = null; usedHuman = null; totalHuman = null; + availableHuman = null; } const result = { @@ -805,7 +801,12 @@ async function collectMetrics(host: SSHHostWithCredentials): Promise<{ usedGiB: usedGiB ? toFixedNum(usedGiB, 2) : null, totalGiB: totalGiB ? toFixedNum(totalGiB, 2) : null, }, - disk: { percent: toFixedNum(diskPercent, 0), usedHuman, totalHuman }, + disk: { + percent: toFixedNum(diskPercent, 0), + usedHuman, + totalHuman, + availableHuman, + }, }; metricsCache.set(host.id, result); @@ -887,7 +888,6 @@ async function pollStatusesOnce(userId?: string): Promise { app.get("/status", async (req, res) => { const userId = (req as any).userId; - // Check if user data is unlocked if (!SimpleDBOps.isUserDataUnlocked(userId)) { return res.status(401).json({ error: "Session expired - please log in again", @@ -909,7 +909,6 @@ app.get("/status/:id", validateHostId, async (req, res) => { const id = Number(req.params.id); const userId = (req as any).userId; - // Check if user data is unlocked if (!SimpleDBOps.isUserDataUnlocked(userId)) { return res.status(401).json({ error: "Session expired - please log in again", @@ -941,7 +940,6 @@ app.get("/status/:id", validateHostId, async (req, res) => { app.post("/refresh", async (req, res) => { const userId = (req as any).userId; - // Check if user data is unlocked if (!SimpleDBOps.isUserDataUnlocked(userId)) { return res.status(401).json({ error: "Session expired - please log in again", @@ -957,7 +955,6 @@ app.get("/metrics/:id", validateHostId, async (req, res) => { const id = Number(req.params.id); const userId = (req as any).userId; - // Check if user data is unlocked if (!SimpleDBOps.isUserDataUnlocked(userId)) { return res.status(401).json({ error: "Session expired - please log in again", diff --git a/src/backend/ssh/terminal.ts b/src/backend/ssh/terminal.ts index 405afabb..94650bd6 100644 --- a/src/backend/ssh/terminal.ts +++ b/src/backend/ssh/terminal.ts @@ -239,7 +239,16 @@ wss.on("connection", async (ws: WebSocket, req) => { } else if (data.startsWith("\x1b")) { sshStream.write(data); } else { - sshStream.write(Buffer.from(data, "utf8")); + try { + sshStream.write(Buffer.from(data, "utf8")); + } catch (error) { + sshLogger.error("Error writing input to SSH stream", error, { + operation: "ssh_input_encoding", + userId, + dataLength: data.length, + }); + sshStream.write(Buffer.from(data, "latin1")); + } } } break; @@ -367,10 +376,11 @@ wss.on("connection", async (ws: WebSocket, req) => { const credential = credentials[0]; resolvedCredentials = { password: credential.password, - key: credential.privateKey || credential.key, - keyPassword: credential.keyPassword, - keyType: credential.keyType, - authType: credential.authType, + key: + credential.private_key || credential.privateKey || credential.key, + keyPassword: credential.key_password || credential.keyPassword, + keyType: credential.key_type || credential.keyType, + authType: credential.auth_type || credential.authType, }; } else { sshLogger.warn(`No credentials found for host ${id}`, { @@ -427,7 +437,22 @@ wss.on("connection", async (ws: WebSocket, req) => { sshStream = stream; stream.on("data", (data: Buffer) => { - ws.send(JSON.stringify({ type: "data", data: data.toString() })); + try { + const utf8String = data.toString("utf-8"); + ws.send(JSON.stringify({ type: "data", data: utf8String })); + } catch (error) { + sshLogger.error("Error encoding terminal data", error, { + operation: "terminal_data_encoding", + hostId: id, + dataLength: data.length, + }); + ws.send( + JSON.stringify({ + type: "data", + data: data.toString("latin1"), + }), + ); + } }); stream.on("close", () => { diff --git a/src/backend/ssh/tunnel.ts b/src/backend/ssh/tunnel.ts index f92dfccb..b49606e6 100644 --- a/src/backend/ssh/tunnel.ts +++ b/src/backend/ssh/tunnel.ts @@ -512,10 +512,11 @@ async function connectSSHTunnel( const credential = credentials[0]; resolvedSourceCredentials = { password: credential.password, - sshKey: credential.privateKey || credential.key, - keyPassword: credential.keyPassword, - keyType: credential.keyType, - authMethod: credential.authType, + sshKey: + credential.private_key || credential.privateKey || credential.key, + keyPassword: credential.key_password || credential.keyPassword, + keyType: credential.key_type || credential.keyType, + authMethod: credential.auth_type || credential.authType, }; } else { } @@ -591,10 +592,11 @@ async function connectSSHTunnel( const credential = credentials[0]; resolvedEndpointCredentials = { password: credential.password, - sshKey: credential.privateKey || credential.key, - keyPassword: credential.keyPassword, - keyType: credential.keyType, - authMethod: credential.authType, + sshKey: + credential.private_key || credential.privateKey || credential.key, + keyPassword: credential.key_password || credential.keyPassword, + keyType: credential.key_type || credential.keyType, + authMethod: credential.auth_type || credential.authType, }; } else { tunnelLogger.warn("No endpoint credentials found in database", { @@ -1025,10 +1027,11 @@ async function killRemoteTunnelByMarker( const credential = credentials[0]; resolvedSourceCredentials = { password: credential.password, - sshKey: credential.privateKey || credential.key, - keyPassword: credential.keyPassword, - keyType: credential.keyType, - authMethod: credential.authType, + sshKey: + credential.private_key || credential.privateKey || credential.key, + keyPassword: credential.key_password || credential.keyPassword, + keyType: credential.key_type || credential.keyType, + authMethod: credential.auth_type || credential.authType, }; } } else { diff --git a/src/backend/utils/data-crypto.ts b/src/backend/utils/data-crypto.ts index 37099a6a..870d0d5f 100644 --- a/src/backend/utils/data-crypto.ts +++ b/src/backend/utils/data-crypto.ts @@ -147,7 +147,7 @@ class DataCrypto { if (needsUpdate) { const updateQuery = ` UPDATE ssh_credentials - SET password = ?, key = ?, key_password = ?, private_key = ?, updated_at = CURRENT_TIMESTAMP + SET password = ?, key = ?, key_password = ?, private_key = ?, public_key = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? `; db.prepare(updateQuery).run( @@ -155,6 +155,7 @@ class DataCrypto { updatedRecord.key || null, updatedRecord.key_password || null, updatedRecord.private_key || null, + updatedRecord.public_key || null, record.id, ); @@ -216,6 +217,165 @@ class DataCrypto { return this.userCrypto.getUserDataKey(userId); } + static async reencryptUserDataAfterPasswordReset( + userId: string, + newUserDataKey: Buffer, + db: any, + ): Promise<{ + success: boolean; + reencryptedTables: string[]; + reencryptedFieldsCount: number; + errors: string[]; + }> { + const result = { + success: false, + reencryptedTables: [] as string[], + reencryptedFieldsCount: 0, + errors: [] as string[], + }; + + try { + const tablesToReencrypt = [ + { table: "ssh_data", fields: ["password", "key", "key_password"] }, + { + table: "ssh_credentials", + fields: [ + "password", + "private_key", + "key_password", + "key", + "public_key", + ], + }, + { + table: "users", + fields: [ + "client_secret", + "totp_secret", + "totp_backup_codes", + "oidc_identifier", + ], + }, + ]; + + for (const { table, fields } of tablesToReencrypt) { + try { + const records = db + .prepare(`SELECT * FROM ${table} WHERE user_id = ?`) + .all(userId); + + for (const record of records) { + const recordId = record.id.toString(); + let needsUpdate = false; + const updatedRecord = { ...record }; + + for (const fieldName of fields) { + const fieldValue = record[fieldName]; + + if (fieldValue && fieldValue.trim() !== "") { + try { + const reencryptedValue = FieldCrypto.encryptField( + fieldValue, + newUserDataKey, + recordId, + fieldName, + ); + + updatedRecord[fieldName] = reencryptedValue; + needsUpdate = true; + result.reencryptedFieldsCount++; + } catch (error) { + const errorMsg = `Failed to re-encrypt ${fieldName} for ${table} record ${recordId}: ${error instanceof Error ? error.message : "Unknown error"}`; + result.errors.push(errorMsg); + databaseLogger.warn( + "Field re-encryption failed during password reset", + { + operation: "password_reset_reencrypt_failed", + userId, + table, + recordId, + fieldName, + error: + error instanceof Error + ? error.message + : "Unknown error", + }, + ); + } + } + } + + if (needsUpdate) { + const updateFields = fields.filter( + (field) => updatedRecord[field] !== record[field], + ); + if (updateFields.length > 0) { + const updateQuery = `UPDATE ${table} SET ${updateFields.map((f) => `${f} = ?`).join(", ")}, updated_at = CURRENT_TIMESTAMP WHERE id = ?`; + const updateValues = updateFields.map( + (field) => updatedRecord[field], + ); + updateValues.push(record.id); + + db.prepare(updateQuery).run(...updateValues); + + if (!result.reencryptedTables.includes(table)) { + result.reencryptedTables.push(table); + } + } + } + } + } catch (tableError) { + const errorMsg = `Failed to re-encrypt table ${table}: ${tableError instanceof Error ? tableError.message : "Unknown error"}`; + result.errors.push(errorMsg); + databaseLogger.error( + "Table re-encryption failed during password reset", + tableError, + { + operation: "password_reset_table_reencrypt_failed", + userId, + table, + error: + tableError instanceof Error + ? tableError.message + : "Unknown error", + }, + ); + } + } + + result.success = result.errors.length === 0; + + databaseLogger.info( + "User data re-encryption completed after password reset", + { + operation: "password_reset_reencrypt_completed", + userId, + success: result.success, + reencryptedTables: result.reencryptedTables, + reencryptedFieldsCount: result.reencryptedFieldsCount, + errorsCount: result.errors.length, + }, + ); + + return result; + } catch (error) { + databaseLogger.error( + "User data re-encryption failed after password reset", + error, + { + operation: "password_reset_reencrypt_failed", + userId, + error: error instanceof Error ? error.message : "Unknown error", + }, + ); + + result.errors.push( + `Critical error during re-encryption: ${error instanceof Error ? error.message : "Unknown error"}`, + ); + return result; + } + } + static validateUserAccess(userId: string): Buffer { const userDataKey = this.getUserDataKey(userId); if (!userDataKey) { diff --git a/src/backend/utils/field-crypto.ts b/src/backend/utils/field-crypto.ts index b225fa8b..098b5b8e 100644 --- a/src/backend/utils/field-crypto.ts +++ b/src/backend/utils/field-crypto.ts @@ -22,13 +22,13 @@ class FieldCrypto { "totp_backup_codes", "oidc_identifier", ]), - ssh_data: new Set(["password", "key", "keyPassword"]), + ssh_data: new Set(["password", "key", "key_password"]), ssh_credentials: new Set([ "password", - "privateKey", - "keyPassword", + "private_key", + "key_password", "key", - "publicKey", + "public_key", ]), }; diff --git a/src/backend/utils/lazy-field-encryption.ts b/src/backend/utils/lazy-field-encryption.ts index 685fa25a..8eae9193 100644 --- a/src/backend/utils/lazy-field-encryption.ts +++ b/src/backend/utils/lazy-field-encryption.ts @@ -2,6 +2,12 @@ import { FieldCrypto } from "./field-crypto.js"; import { databaseLogger } from "./logger.js"; export class LazyFieldEncryption { + private static readonly LEGACY_FIELD_NAME_MAP: Record = { + key_password: "keyPassword", + private_key: "privateKey", + public_key: "publicKey", + }; + static isPlaintextField(value: string): boolean { if (!value) return false; @@ -44,6 +50,35 @@ export class LazyFieldEncryption { ); return decrypted; } catch (error) { + const legacyFieldName = this.LEGACY_FIELD_NAME_MAP[fieldName]; + if (legacyFieldName) { + try { + const decrypted = FieldCrypto.decryptField( + fieldValue, + userKEK, + recordId, + legacyFieldName, + ); + return decrypted; + } catch (legacyError) {} + } + + const sensitiveFields = [ + "totp_secret", + "totp_backup_codes", + "password", + "key", + "key_password", + "private_key", + "public_key", + "client_secret", + "oidc_identifier", + ]; + + if (sensitiveFields.includes(fieldName)) { + return ""; + } + databaseLogger.error("Failed to decrypt field", error, { operation: "lazy_encryption_decrypt_failed", recordId, @@ -60,9 +95,13 @@ export class LazyFieldEncryption { userKEK: Buffer, recordId: string, fieldName: string, - ): { encrypted: string; wasPlaintext: boolean } { + ): { + encrypted: string; + wasPlaintext: boolean; + wasLegacyEncryption: boolean; + } { if (!fieldValue) { - return { encrypted: "", wasPlaintext: false }; + return { encrypted: "", wasPlaintext: false, wasLegacyEncryption: false }; } if (this.isPlaintextField(fieldValue)) { @@ -74,7 +113,7 @@ export class LazyFieldEncryption { fieldName, ); - return { encrypted, wasPlaintext: true }; + return { encrypted, wasPlaintext: true, wasLegacyEncryption: false }; } catch (error) { databaseLogger.error("Failed to encrypt plaintext field", error, { operation: "lazy_encryption_migrate_failed", @@ -85,7 +124,42 @@ export class LazyFieldEncryption { throw error; } } else { - return { encrypted: fieldValue, wasPlaintext: false }; + try { + FieldCrypto.decryptField(fieldValue, userKEK, recordId, fieldName); + return { + encrypted: fieldValue, + wasPlaintext: false, + wasLegacyEncryption: false, + }; + } catch (error) { + const legacyFieldName = this.LEGACY_FIELD_NAME_MAP[fieldName]; + if (legacyFieldName) { + try { + const decrypted = FieldCrypto.decryptField( + fieldValue, + userKEK, + recordId, + legacyFieldName, + ); + const reencrypted = FieldCrypto.encryptField( + decrypted, + userKEK, + recordId, + fieldName, + ); + return { + encrypted: reencrypted, + wasPlaintext: false, + wasLegacyEncryption: true, + }; + } catch (legacyError) {} + } + return { + encrypted: fieldValue, + wasPlaintext: false, + wasLegacyEncryption: false, + }; + } } } @@ -106,18 +180,21 @@ export class LazyFieldEncryption { for (const fieldName of sensitiveFields) { const fieldValue = record[fieldName]; - if (fieldValue && this.isPlaintextField(fieldValue)) { + if (fieldValue) { try { - const { encrypted } = this.migrateFieldToEncrypted( - fieldValue, - userKEK, - recordId, - fieldName, - ); + const { encrypted, wasPlaintext, wasLegacyEncryption } = + this.migrateFieldToEncrypted( + fieldValue, + userKEK, + recordId, + fieldName, + ); - updatedRecord[fieldName] = encrypted; - migratedFields.push(fieldName); - needsUpdate = true; + if (wasPlaintext || wasLegacyEncryption) { + updatedRecord[fieldName] = encrypted; + migratedFields.push(fieldName); + needsUpdate = true; + } } catch (error) { databaseLogger.error("Failed to migrate record field", error, { operation: "lazy_encryption_record_field_failed", @@ -134,13 +211,53 @@ export class LazyFieldEncryption { static getSensitiveFieldsForTable(tableName: string): string[] { const sensitiveFieldsMap: Record = { ssh_data: ["password", "key", "key_password"], - ssh_credentials: ["password", "key", "key_password", "private_key"], + ssh_credentials: [ + "password", + "key", + "key_password", + "private_key", + "public_key", + ], users: ["totp_secret", "totp_backup_codes"], }; return sensitiveFieldsMap[tableName] || []; } + static fieldNeedsMigration( + fieldValue: string, + userKEK: Buffer, + recordId: string, + fieldName: string, + ): boolean { + if (!fieldValue) return false; + + if (this.isPlaintextField(fieldValue)) { + return true; + } + + try { + FieldCrypto.decryptField(fieldValue, userKEK, recordId, fieldName); + return false; + } catch (error) { + const legacyFieldName = this.LEGACY_FIELD_NAME_MAP[fieldName]; + if (legacyFieldName) { + try { + FieldCrypto.decryptField( + fieldValue, + userKEK, + recordId, + legacyFieldName, + ); + return true; + } catch (legacyError) { + return false; + } + } + return false; + } + } + static async checkUserNeedsMigration( userId: string, userKEK: Buffer, @@ -169,7 +286,15 @@ export class LazyFieldEncryption { const hostPlaintextFields: string[] = []; for (const field of sensitiveFields) { - if (host[field] && this.isPlaintextField(host[field])) { + if ( + host[field] && + this.fieldNeedsMigration( + host[field], + userKEK, + host.id.toString(), + field, + ) + ) { hostPlaintextFields.push(field); needsMigration = true; } @@ -193,7 +318,15 @@ export class LazyFieldEncryption { const credentialPlaintextFields: string[] = []; for (const field of sensitiveFields) { - if (credential[field] && this.isPlaintextField(credential[field])) { + if ( + credential[field] && + this.fieldNeedsMigration( + credential[field], + userKEK, + credential.id.toString(), + field, + ) + ) { credentialPlaintextFields.push(field); needsMigration = true; } @@ -214,7 +347,10 @@ export class LazyFieldEncryption { const userPlaintextFields: string[] = []; for (const field of sensitiveFields) { - if (user[field] && this.isPlaintextField(user[field])) { + if ( + user[field] && + this.fieldNeedsMigration(user[field], userKEK, userId, field) + ) { userPlaintextFields.push(field); needsMigration = true; } diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 262cec0a..0e6d735e 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -564,10 +564,11 @@ "downloadSample": "Download Sample", "formatGuide": "Format Guide", "exportCredentialWarning": "Warning: Host \"{{name}}\" uses credential authentication. The exported file will not include the credential data and will need to be manually reconfigured after import. Do you want to continue?", - "exportSensitiveDataWarning": "Warning: Host \"{{name}}\" contains sensitive authentication data (password/SSH key). The exported file will not include this data for security reasons. You'll need to reconfigure authentication after import. Do you want to continue?", + "exportSensitiveDataWarning": "Warning: Host \"{{name}}\" contains sensitive authentication data (password/SSH key). The exported file will include this data in plaintext. Please keep the file secure and delete it after use. Do you want to continue?", "uncategorized": "Uncategorized", "confirmDelete": "Are you sure you want to delete \"{{name}}\" ?", "failedToDeleteHost": "Failed to delete host", + "failedToExportHost": "Failed to export host. Please ensure you're logged in and have access to the host data.", "jsonMustContainHosts": "JSON must contain a \"hosts\" array or be an array of hosts", "noHostsInJson": "No hosts found in JSON file", "maxHostsAllowed": "Maximum 100 hosts allowed per import", @@ -978,7 +979,16 @@ "move": "Move", "searchInFile": "Search in file (Ctrl+F)", "showKeyboardShortcuts": "Show keyboard shortcuts", - "startWritingMarkdown": "Start writing your markdown content..." + "startWritingMarkdown": "Start writing your markdown content...", + "loadingFileComparison": "Loading file comparison...", + "reload": "Reload", + "compare": "Compare", + "sideBySide": "Side by Side", + "inline": "Inline", + "fileComparison": "File Comparison: {{file1}} vs {{file2}}", + "fileTooLarge": "File too large: {{error}}", + "sshConnectionFailed": "SSH connection failed. Please check your connection to {{name}} ({{ip}}:{{port}})", + "loadFileFailed": "Failed to load file: {{error}}" }, "tunnels": { "title": "SSH Tunnels", diff --git a/src/locales/zh/translation.json b/src/locales/zh/translation.json index 5b7ba6b1..62069e11 100644 --- a/src/locales/zh/translation.json +++ b/src/locales/zh/translation.json @@ -548,10 +548,11 @@ "downloadSample": "下载示例", "formatGuide": "格式指南", "exportCredentialWarning": "警告:主机 \"{{name}}\" 使用凭据认证。导出的文件将不包含凭据数据,导入后需要手动重新配置。您确定要继续吗?", - "exportSensitiveDataWarning": "警告:主机 \"{{name}}\" 包含敏感认证数据(密码/SSH密钥)。出于安全考虑,导出的文件将不包含此数据。导入后您需要重新配置认证。您确定要继续吗?", + "exportSensitiveDataWarning": "警告:主机 \"{{name}}\" 包含敏感认证数据(密码/SSH密钥)。导出的文件将以明文形式包含这些数据。请妥善保管文件,使用后建议删除。您确定要继续吗?", "uncategorized": "未分类", "confirmDelete": "确定要删除 \"{{name}}\" 吗?", "failedToDeleteHost": "删除主机失败", + "failedToExportHost": "导出主机失败。请确保您已登录并有权访问主机数据。", "jsonMustContainHosts": "JSON 必须包含 \"hosts\" 数组或是一个主机数组", "noHostsInJson": "JSON 文件中未找到主机", "maxHostsAllowed": "每次导入最多允许 100 个主机", @@ -969,7 +970,16 @@ "move": "移动", "searchInFile": "在文件中搜索 (Ctrl+F)", "showKeyboardShortcuts": "显示键盘快捷键", - "startWritingMarkdown": "开始编写您的 markdown 内容..." + "startWritingMarkdown": "开始编写您的 markdown 内容...", + "loadingFileComparison": "正在加载文件对比...", + "reload": "重新加载", + "compare": "对比", + "sideBySide": "并排显示", + "inline": "内嵌显示", + "fileComparison": "文件对比:{{file1}} 与 {{file2}}", + "fileTooLarge": "文件过大:{{error}}", + "sshConnectionFailed": "SSH 连接失败。请检查与 {{name}} ({{ip}}:{{port}}) 的连接", + "loadFileFailed": "加载文件失败:{{error}}" }, "tunnels": { "title": "SSH 隧道", diff --git a/src/ui/Desktop/Apps/File Manager/components/DiffViewer.tsx b/src/ui/Desktop/Apps/File Manager/components/DiffViewer.tsx index 90c4b609..e9312924 100644 --- a/src/ui/Desktop/Apps/File Manager/components/DiffViewer.tsx +++ b/src/ui/Desktop/Apps/File Manager/components/DiffViewer.tsx @@ -17,7 +17,7 @@ import { getSSHStatus, connectSSH, } from "@/ui/main-axios"; -import type { FileItem, SSHHost } from "../../../../types/index.js"; +import type { FileItem, SSHHost } from "@/types/index"; interface DiffViewerProps { file1: FileItem; @@ -62,8 +62,22 @@ export function DiffViewer({ }); } } catch (error) { - console.error("SSH connection check/reconnect failed:", error); - throw error; + try { + await connectSSH(sshSessionId, { + hostId: sshHost.id, + ip: sshHost.ip, + port: sshHost.port, + username: sshHost.username, + password: sshHost.password, + sshKey: sshHost.key, + keyPassword: sshHost.keyPassword, + authType: sshHost.authType, + credentialId: sshHost.credentialId, + userId: sshHost.userId, + }); + } catch (reconnectError) { + throw reconnectError; + } } }; @@ -310,7 +324,6 @@ export function DiffViewer({ automaticLayout: true, readOnly: true, originalEditable: false, - modifiedEditable: false, scrollbar: { vertical: "visible", horizontal: "visible", diff --git a/src/ui/Desktop/Apps/File Manager/components/DraggableWindow.tsx b/src/ui/Desktop/Apps/File Manager/components/DraggableWindow.tsx index d214ab93..5d26e07b 100644 --- a/src/ui/Desktop/Apps/File Manager/components/DraggableWindow.tsx +++ b/src/ui/Desktop/Apps/File Manager/components/DraggableWindow.tsx @@ -15,6 +15,7 @@ interface DraggableWindowProps { onClose: () => void; onMinimize?: () => void; onMaximize?: () => void; + onResize?: () => void; isMaximized?: boolean; zIndex?: number; onFocus?: () => void; @@ -33,6 +34,7 @@ export function DraggableWindow({ onClose, onMinimize, onMaximize, + onResize, isMaximized = false, zIndex = 1000, onFocus, @@ -197,6 +199,10 @@ export function DraggableWindow({ setSize({ width: newWidth, height: newHeight }); setPosition({ x: newX, y: newY }); + + if (onResize) { + onResize(); + } } }, [ @@ -211,6 +217,7 @@ export function DraggableWindow({ minWidth, minHeight, resizeDirection, + onResize, ], ); diff --git a/src/ui/Desktop/Apps/File Manager/components/FileViewer.tsx b/src/ui/Desktop/Apps/File Manager/components/FileViewer.tsx index 651e8ca1..1a3cb109 100644 --- a/src/ui/Desktop/Apps/File Manager/components/FileViewer.tsx +++ b/src/ui/Desktop/Apps/File Manager/components/FileViewer.tsx @@ -1257,17 +1257,6 @@ export function FileViewer({ - {onDownload && ( - - )} diff --git a/src/ui/Desktop/Apps/File Manager/components/TerminalWindow.tsx b/src/ui/Desktop/Apps/File Manager/components/TerminalWindow.tsx index 6069e8a7..880ab5e8 100644 --- a/src/ui/Desktop/Apps/File Manager/components/TerminalWindow.tsx +++ b/src/ui/Desktop/Apps/File Manager/components/TerminalWindow.tsx @@ -38,6 +38,8 @@ export function TerminalWindow({ const { t } = useTranslation(); const { closeWindow, minimizeWindow, maximizeWindow, focusWindow, windows } = useWindowManager(); + const terminalRef = React.useRef(null); + const resizeTimeoutRef = React.useRef(null); const currentWindow = windows.find((w) => w.id === windowId); if (!currentWindow) { @@ -60,6 +62,26 @@ export function TerminalWindow({ focusWindow(windowId); }; + const handleResize = () => { + if (resizeTimeoutRef.current) { + clearTimeout(resizeTimeoutRef.current); + } + + resizeTimeoutRef.current = setTimeout(() => { + if (terminalRef.current?.fit) { + terminalRef.current.fit(); + } + }, 100); + }; + + React.useEffect(() => { + return () => { + if (resizeTimeoutRef.current) { + clearTimeout(resizeTimeoutRef.current); + } + }; + }, []); + const terminalTitle = executeCommand ? t("terminal.runTitle", { host: hostConfig.name, command: executeCommand }) : initialPath @@ -81,10 +103,12 @@ export function TerminalWindow({ onClose={handleClose} onMaximize={handleMaximize} onFocus={handleFocus} + onResize={handleResize} isMaximized={currentWindow.isMaximized} zIndex={currentWindow.zIndex} > { - const exportData: any = { - name: host.name, - ip: host.ip, - port: host.port, - username: host.username, - authType: actualAuthType, - folder: host.folder, - tags: host.tags, - pin: host.pin, - enableTerminal: host.enableTerminal, - enableTunnel: host.enableTunnel, - enableFileManager: host.enableFileManager, - defaultPath: host.defaultPath, - tunnelConnections: host.tunnelConnections, - }; + const performExport = async (host: SSHHost, actualAuthType: string) => { + try { + const decryptedHost = await exportSSHHostWithCredentials(host.id); - if (actualAuthType === "credential") { - exportData.credentialId = null; + const cleanExportData = Object.fromEntries( + Object.entries(decryptedHost).filter( + ([_, value]) => value !== undefined, + ), + ); + + const blob = new Blob([JSON.stringify(cleanExportData, null, 2)], { + type: "application/json", + }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `${host.name || host.username + "@" + host.ip}-host-config.json`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + + toast.success( + `Exported host configuration for ${host.name || host.username}@${host.ip}`, + ); + } catch (error) { + toast.error(t("hosts.failedToExportHost")); } - - const cleanExportData = Object.fromEntries( - Object.entries(exportData).filter(([_, value]) => value !== undefined), - ); - - const blob = new Blob([JSON.stringify(cleanExportData, null, 2)], { - type: "application/json", - }); - const url = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = `${host.name || host.username + "@" + host.ip}-host-config.json`; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); - - toast.success( - `Exported host configuration for ${host.name || host.username}@${host.ip}`, - ); }; const handleEdit = (host: SSHHost) => { diff --git a/src/ui/Desktop/Apps/Server/Server.tsx b/src/ui/Desktop/Apps/Server/Server.tsx index ce5ec323..413f4613 100644 --- a/src/ui/Desktop/Apps/Server/Server.tsx +++ b/src/ui/Desktop/Apps/Server/Server.tsx @@ -434,10 +434,9 @@ export function Server({
{(() => { - const used = metrics?.disk?.usedHuman; - const total = metrics?.disk?.totalHuman; - return used && total - ? `Available: ${total}` + const available = metrics?.disk?.availableHuman; + return available + ? `Available: ${available}` : "Available: N/A"; })()}
diff --git a/src/ui/Desktop/Apps/Terminal/Terminal.tsx b/src/ui/Desktop/Apps/Terminal/Terminal.tsx index 5a686ec5..7367ff6f 100644 --- a/src/ui/Desktop/Apps/Terminal/Terminal.tsx +++ b/src/ui/Desktop/Apps/Terminal/Terminal.tsx @@ -352,7 +352,11 @@ export const Terminal = forwardRef(function SSHTerminal( try { const msg = JSON.parse(event.data); if (msg.type === "data") { - terminal.write(msg.data); + if (typeof msg.data === "string") { + terminal.write(msg.data); + } else { + terminal.write(String(msg.data)); + } } else if (msg.type === "error") { const errorMessage = msg.message || t("terminal.unknownError"); @@ -520,6 +524,9 @@ export const Terminal = forwardRef(function SSHTerminal( fastScrollModifier: "alt", fastScrollSensitivity: 5, allowProposedApi: true, + minimumContrastRatio: 1, + letterSpacing: 0, + lineHeight: 1.2, }; const fitAddon = new FitAddon(); @@ -532,6 +539,9 @@ export const Terminal = forwardRef(function SSHTerminal( terminal.loadAddon(clipboardAddon); terminal.loadAddon(unicode11Addon); terminal.loadAddon(webLinksAddon); + + terminal.unicode.activeVersion = "11"; + terminal.open(xtermRef.current); const element = xtermRef.current; @@ -796,5 +806,69 @@ style.innerHTML = ` .xterm .xterm-screen .xterm-char[data-char-code^="\\uE"] { font-family: 'JetBrains Mono Nerd Font', 'MesloLGS NF', 'FiraCode Nerd Font' !important; } + +.xterm .xterm-screen .xterm-char[data-char-code^="\\uE000"] { + font-family: 'JetBrains Mono Nerd Font', 'MesloLGS NF', 'FiraCode Nerd Font' !important; +} + +.xterm .xterm-screen .xterm-char[data-char-code^="\\uE001"] { + font-family: 'JetBrains Mono Nerd Font', 'MesloLGS NF', 'FiraCode Nerd Font' !important; +} + +.xterm .xterm-screen .xterm-char[data-char-code^="\\uE002"] { + font-family: 'JetBrains Mono Nerd Font', 'MesloLGS NF', 'FiraCode Nerd Font' !important; +} + +.xterm .xterm-screen .xterm-char[data-char-code^="\\uE003"] { + font-family: 'JetBrains Mono Nerd Font', 'MesloLGS NF', 'FiraCode Nerd Font' !important; +} + +.xterm .xterm-screen .xterm-char[data-char-code^="\\uE004"] { + font-family: 'JetBrains Mono Nerd Font', 'MesloLGS NF', 'FiraCode Nerd Font' !important; +} + +.xterm .xterm-screen .xterm-char[data-char-code^="\\uE005"] { + font-family: 'JetBrains Mono Nerd Font', 'MesloLGS NF', 'FiraCode Nerd Font' !important; +} + +.xterm .xterm-screen .xterm-char[data-char-code^="\\uE006"] { + font-family: 'JetBrains Mono Nerd Font', 'MesloLGS NF', 'FiraCode Nerd Font' !important; +} + +.xterm .xterm-screen .xterm-char[data-char-code^="\\uE007"] { + font-family: 'JetBrains Mono Nerd Font', 'MesloLGS NF', 'FiraCode Nerd Font' !important; +} + +.xterm .xterm-screen .xterm-char[data-char-code^="\\uE008"] { + font-family: 'JetBrains Mono Nerd Font', 'MesloLGS NF', 'FiraCode Nerd Font' !important; +} + +.xterm .xterm-screen .xterm-char[data-char-code^="\\uE009"] { + font-family: 'JetBrains Mono Nerd Font', 'MesloLGS NF', 'FiraCode Nerd Font' !important; +} + +.xterm .xterm-screen .xterm-char[data-char-code^="\\uE00A"] { + font-family: 'JetBrains Mono Nerd Font', 'MesloLGS NF', 'FiraCode Nerd Font' !important; +} + +.xterm .xterm-screen .xterm-char[data-char-code^="\\uE00B"] { + font-family: 'JetBrains Mono Nerd Font', 'MesloLGS NF', 'FiraCode Nerd Font' !important; +} + +.xterm .xterm-screen .xterm-char[data-char-code^="\\uE00C"] { + font-family: 'JetBrains Mono Nerd Font', 'MesloLGS NF', 'FiraCode Nerd Font' !important; +} + +.xterm .xterm-screen .xterm-char[data-char-code^="\\uE00D"] { + font-family: 'JetBrains Mono Nerd Font', 'MesloLGS NF', 'FiraCode Nerd Font' !important; +} + +.xterm .xterm-screen .xterm-char[data-char-code^="\\uE00E"] { + font-family: 'JetBrains Mono Nerd Font', 'MesloLGS NF', 'FiraCode Nerd Font' !important; +} + +.xterm .xterm-screen .xterm-char[data-char-code^="\\uE00F"] { + font-family: 'JetBrains Mono Nerd Font', 'MesloLGS NF', 'FiraCode Nerd Font' !important; +} `; document.head.appendChild(style); diff --git a/src/ui/Mobile/Apps/Terminal/Terminal.tsx b/src/ui/Mobile/Apps/Terminal/Terminal.tsx index c2470658..ac164b98 100644 --- a/src/ui/Mobile/Apps/Terminal/Terminal.tsx +++ b/src/ui/Mobile/Apps/Terminal/Terminal.tsx @@ -158,8 +158,13 @@ export const Terminal = forwardRef(function SSHTerminal( ws.addEventListener("message", (event) => { try { const msg = JSON.parse(event.data); - if (msg.type === "data") terminal.write(msg.data); - else if (msg.type === "error") + if (msg.type === "data") { + if (typeof msg.data === "string") { + terminal.write(msg.data); + } else { + terminal.write(String(msg.data)); + } + } else if (msg.type === "error") terminal.writeln(`\r\n[${t("terminal.error")}] ${msg.message}`); else if (msg.type === "connected") { isConnectingRef.current = false; @@ -221,6 +226,9 @@ export const Terminal = forwardRef(function SSHTerminal( allowProposedApi: true, disableStdin: true, cursorInactiveStyle: "bar", + minimumContrastRatio: 1, + letterSpacing: 0, + lineHeight: 1.2, }; const fitAddon = new FitAddon(); @@ -233,6 +241,9 @@ export const Terminal = forwardRef(function SSHTerminal( terminal.loadAddon(clipboardAddon); terminal.loadAddon(unicode11Addon); terminal.loadAddon(webLinksAddon); + + terminal.unicode.activeVersion = "11"; + terminal.open(xtermRef.current); const textarea = xtermRef.current.querySelector( @@ -444,5 +455,65 @@ style.innerHTML = ` .xterm .xterm-screen .xterm-char[data-char-code^="\uE000"] { font-family: 'JetBrains Mono Nerd Font', 'MesloLGS NF', 'FiraCode Nerd Font' !important; } + +.xterm .xterm-screen .xterm-char[data-char-code^="\uE001"] { + font-family: 'JetBrains Mono Nerd Font', 'MesloLGS NF', 'FiraCode Nerd Font' !important; +} + +.xterm .xterm-screen .xterm-char[data-char-code^="\uE002"] { + font-family: 'JetBrains Mono Nerd Font', 'MesloLGS NF', 'FiraCode Nerd Font' !important; +} + +.xterm .xterm-screen .xterm-char[data-char-code^="\uE003"] { + font-family: 'JetBrains Mono Nerd Font', 'MesloLGS NF', 'FiraCode Nerd Font' !important; +} + +.xterm .xterm-screen .xterm-char[data-char-code^="\uE004"] { + font-family: 'JetBrains Mono Nerd Font', 'MesloLGS NF', 'FiraCode Nerd Font' !important; +} + +.xterm .xterm-screen .xterm-char[data-char-code^="\uE005"] { + font-family: 'JetBrains Mono Nerd Font', 'MesloLGS NF', 'FiraCode Nerd Font' !important; +} + +.xterm .xterm-screen .xterm-char[data-char-code^="\uE006"] { + font-family: 'JetBrains Mono Nerd Font', 'MesloLGS NF', 'FiraCode Nerd Font' !important; +} + +.xterm .xterm-screen .xterm-char[data-char-code^="\uE007"] { + font-family: 'JetBrains Mono Nerd Font', 'MesloLGS NF', 'FiraCode Nerd Font' !important; +} + +.xterm .xterm-screen .xterm-char[data-char-code^="\uE008"] { + font-family: 'JetBrains Mono Nerd Font', 'MesloLGS NF', 'FiraCode Nerd Font' !important; +} + +.xterm .xterm-screen .xterm-char[data-char-code^="\uE009"] { + font-family: 'JetBrains Mono Nerd Font', 'MesloLGS NF', 'FiraCode Nerd Font' !important; +} + +.xterm .xterm-screen .xterm-char[data-char-code^="\uE00A"] { + font-family: 'JetBrains Mono Nerd Font', 'MesloLGS NF', 'FiraCode Nerd Font' !important; +} + +.xterm .xterm-screen .xterm-char[data-char-code^="\uE00B"] { + font-family: 'JetBrains Mono Nerd Font', 'MesloLGS NF', 'FiraCode Nerd Font' !important; +} + +.xterm .xterm-screen .xterm-char[data-char-code^="\uE00C"] { + font-family: 'JetBrains Mono Nerd Font', 'MesloLGS NF', 'FiraCode Nerd Font' !important; +} + +.xterm .xterm-screen .xterm-char[data-char-code^="\uE00D"] { + font-family: 'JetBrains Mono Nerd Font', 'MesloLGS NF', 'FiraCode Nerd Font' !important; +} + +.xterm .xterm-screen .xterm-char[data-char-code^="\uE00E"] { + font-family: 'JetBrains Mono Nerd Font', 'MesloLGS NF', 'FiraCode Nerd Font' !important; +} + +.xterm .xterm-screen .xterm-char[data-char-code^="\uE00F"] { + font-family: 'JetBrains Mono Nerd Font', 'MesloLGS NF', 'FiraCode Nerd Font' !important; +} `; document.head.appendChild(style); diff --git a/src/ui/main-axios.ts b/src/ui/main-axios.ts index 8a0ffb04..c1f2a660 100644 --- a/src/ui/main-axios.ts +++ b/src/ui/main-axios.ts @@ -51,6 +51,7 @@ interface DiskMetrics { percent: number | null; usedHuman: string | null; totalHuman: string | null; + availableHuman?: string | null; } export type ServerMetrics = { @@ -796,6 +797,17 @@ export async function getSSHHostById(hostId: number): Promise { } } +export async function exportSSHHostWithCredentials( + hostId: number, +): Promise { + try { + const response = await sshHostApi.get(`/db/host/${hostId}/export`); + return response.data; + } catch (error) { + handleApiError(error, "export SSH host with credentials"); + } +} + // ============================================================================ // SSH AUTOSTART MANAGEMENT // ============================================================================ -- 2.49.1