Fix critical UI and data export issues #327

Merged
ZacharyZcR merged 4 commits from main into dev-1.7.1 2025-10-02 21:29:37 +00:00
10 changed files with 144 additions and 46 deletions
+78
View File
@@ -670,6 +670,84 @@ router.get(
}, },
); );
// Route: Export SSH host with decrypted credentials (requires data access)
// GET /ssh/db/host/:id/export
router.get(
gemini-code-assist[bot] commented 2025-10-02 07:08:20 +00:00 (Migrated from github.com)
Review

high

Using a GET request to export sensitive data can be a security risk. GET requests, including their paths, are often logged by servers, proxies, and in browser history. It's a best practice to use POST for actions that involve sensitive data to avoid leaking identifiers like hostId in logs.

You'll also need to update the corresponding frontend client in src/ui/main-axios.ts to use sshHostApi.post.

router.post(
![high](https://www.gstatic.com/codereviewagent/high-priority.svg) Using a `GET` request to export sensitive data can be a security risk. GET requests, including their paths, are often logged by servers, proxies, and in browser history. It's a best practice to use `POST` for actions that involve sensitive data to avoid leaking identifiers like `hostId` in logs. You'll also need to update the corresponding frontend client in `src/ui/main-axios.ts` to use `sshHostApi.post`. ```suggestion router.post( ```
"/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" });
}
gemini-code-assist[bot] commented 2025-10-02 07:08:20 +00:00 (Migrated from github.com)
Review

medium

The hostId from req.params.id is used as a string and converted to a number in multiple places. This can lead to inconsistencies and potential issues if the parameter is not a valid number. It's more robust to parse and validate it once at the beginning of the handler. After this change, you can use the hostId variable directly as a number throughout the function, removing the need for Number(hostId) and parseInt(hostId).

    const hostId = parseInt(req.params.id, 10);
    const userId = (req as any).userId;

    if (!isNonEmptyString(userId) || isNaN(hostId)) {
      return res.status(400).json({ error: "Invalid userId or hostId" });
    }
![medium](https://www.gstatic.com/codereviewagent/medium-priority.svg) The `hostId` from `req.params.id` is used as a string and converted to a number in multiple places. This can lead to inconsistencies and potential issues if the parameter is not a valid number. It's more robust to parse and validate it once at the beginning of the handler. After this change, you can use the `hostId` variable directly as a number throughout the function, removing the need for `Number(hostId)` and `parseInt(hostId)`. ```suggestion const hostId = parseInt(req.params.id, 10); const userId = (req as any).userId; if (!isNonEmptyString(userId) || isNaN(hostId)) { return res.status(400).json({ error: "Invalid userId or hostId" }); } ```
try {
// Fetch decrypted host data using SimpleDBOps
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];
// Resolve credentials if using credential-based auth
const resolvedHost = (await resolveHostCredentials(host)) || host;
// Format for export (include all fields including sensitive data)
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) // Route: Delete SSH host by id (requires JWT)
// DELETE /ssh/host/:id // DELETE /ssh/host/:id
router.delete( router.delete(
+9 -1
View File
@@ -750,6 +750,7 @@ async function collectMetrics(host: SSHHostWithCredentials): Promise<{
let diskPercent: number | null = null; let diskPercent: number | null = null;
let usedHuman: string | null = null; let usedHuman: string | null = null;
let totalHuman: string | null = null; let totalHuman: string | null = null;
let availableHuman: string | null = null;
try { try {
const [diskOutHuman, diskOutBytes] = await Promise.all([ const [diskOutHuman, diskOutBytes] = await Promise.all([
execCommand(client, "df -h -P / | tail -n +2"), execCommand(client, "df -h -P / | tail -n +2"),
@@ -773,6 +774,7 @@ async function collectMetrics(host: SSHHostWithCredentials): Promise<{
if (humanParts.length >= 6 && bytesParts.length >= 6) { if (humanParts.length >= 6 && bytesParts.length >= 6) {
totalHuman = humanParts[1] || null; totalHuman = humanParts[1] || null;
usedHuman = humanParts[2] || null; usedHuman = humanParts[2] || null;
availableHuman = humanParts[3] || null; // Parse Available column from df output
const totalBytes = Number(bytesParts[1]); const totalBytes = Number(bytesParts[1]);
const usedBytes = Number(bytesParts[2]); const usedBytes = Number(bytesParts[2]);
@@ -796,6 +798,7 @@ async function collectMetrics(host: SSHHostWithCredentials): Promise<{
diskPercent = null; diskPercent = null;
usedHuman = null; usedHuman = null;
totalHuman = null; totalHuman = null;
availableHuman = null;
} }
const result = { const result = {
@@ -805,7 +808,12 @@ async function collectMetrics(host: SSHHostWithCredentials): Promise<{
usedGiB: usedGiB ? toFixedNum(usedGiB, 2) : null, usedGiB: usedGiB ? toFixedNum(usedGiB, 2) : null,
totalGiB: totalGiB ? toFixedNum(totalGiB, 2) : null, totalGiB: totalGiB ? toFixedNum(totalGiB, 2) : null,
}, },
disk: { percent: toFixedNum(diskPercent, 0), usedHuman, totalHuman }, disk: {
percent: toFixedNum(diskPercent, 0),
usedHuman,
totalHuman,
availableHuman, // Include available space in response
},
}; };
metricsCache.set(host.id, result); metricsCache.set(host.id, result);
+1 -1
View File
@@ -427,7 +427,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
sshStream = stream; sshStream = stream;
stream.on("data", (data: Buffer) => { stream.on("data", (data: Buffer) => {
ws.send(JSON.stringify({ type: "data", data: data.toString() })); ws.send(JSON.stringify({ type: "data", data: data.toString("utf-8") }));
}); });
stream.on("close", () => { stream.on("close", () => {
+2 -1
View File
@@ -564,10 +564,11 @@
"downloadSample": "Download Sample", "downloadSample": "Download Sample",
"formatGuide": "Format Guide", "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?", "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", "uncategorized": "Uncategorized",
"confirmDelete": "Are you sure you want to delete \"{{name}}\" ?", "confirmDelete": "Are you sure you want to delete \"{{name}}\" ?",
"failedToDeleteHost": "Failed to delete host", "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", "jsonMustContainHosts": "JSON must contain a \"hosts\" array or be an array of hosts",
"noHostsInJson": "No hosts found in JSON file", "noHostsInJson": "No hosts found in JSON file",
"maxHostsAllowed": "Maximum 100 hosts allowed per import", "maxHostsAllowed": "Maximum 100 hosts allowed per import",
+2 -1
View File
@@ -548,10 +548,11 @@
"downloadSample": "下载示例", "downloadSample": "下载示例",
"formatGuide": "格式指南", "formatGuide": "格式指南",
"exportCredentialWarning": "警告:主机 \"{{name}}\" 使用凭据认证。导出的文件将不包含凭据数据,导入后需要手动重新配置。您确定要继续吗?", "exportCredentialWarning": "警告:主机 \"{{name}}\" 使用凭据认证。导出的文件将不包含凭据数据,导入后需要手动重新配置。您确定要继续吗?",
"exportSensitiveDataWarning": "警告:主机 \"{{name}}\" 包含敏感认证数据(密码/SSH密钥)。出于安全考虑,导出的文件将不包含此数据。导入后您需要重新配置认证。您确定要继续吗?", "exportSensitiveDataWarning": "警告:主机 \"{{name}}\" 包含敏感认证数据(密码/SSH密钥)。导出的文件将以明文形式包含这些数据。请妥善保管文件,使用后建议删除。您确定要继续吗?",
"uncategorized": "未分类", "uncategorized": "未分类",
"confirmDelete": "确定要删除 \"{{name}}\" 吗?", "confirmDelete": "确定要删除 \"{{name}}\" 吗?",
"failedToDeleteHost": "删除主机失败", "failedToDeleteHost": "删除主机失败",
"failedToExportHost": "导出主机失败。请确保您已登录并有权访问主机数据。",
"jsonMustContainHosts": "JSON 必须包含 \"hosts\" 数组或是一个主机数组", "jsonMustContainHosts": "JSON 必须包含 \"hosts\" 数组或是一个主机数组",
"noHostsInJson": "JSON 文件中未找到主机", "noHostsInJson": "JSON 文件中未找到主机",
"maxHostsAllowed": "每次导入最多允许 100 个主机", "maxHostsAllowed": "每次导入最多允许 100 个主机",
@@ -21,6 +21,7 @@ import {
bulkImportSSHHosts, bulkImportSSHHosts,
updateSSHHost, updateSSHHost,
renameFolder, renameFolder,
exportSSHHostWithCredentials,
} from "@/ui/main-axios.ts"; } from "@/ui/main-axios.ts";
import { toast } from "sonner"; import { toast } from "sonner";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@@ -159,46 +160,36 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
performExport(host, actualAuthType); performExport(host, actualAuthType);
}; };
const performExport = (host: SSHHost, actualAuthType: string) => { const performExport = async (host: SSHHost, actualAuthType: string) => {
const exportData: any = { try {
name: host.name, // Fetch decrypted host data from backend
ip: host.ip, const decryptedHost = await exportSSHHostWithCredentials(host.id);
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,
};
if (actualAuthType === "credential") { // Use decrypted data for export (includes password, key, etc.)
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"));
gemini-code-assist[bot] commented 2025-10-02 07:08:20 +00:00 (Migrated from github.com)
Review

medium

The actualAuthType parameter is passed to the performExport function but is no longer used within its body. Removing it will clean up the code. You will also need to update the call sites for this function in handleExport.

  const performExport = async (host: SSHHost) => {
![medium](https://www.gstatic.com/codereviewagent/medium-priority.svg) The `actualAuthType` parameter is passed to the `performExport` function but is no longer used within its body. Removing it will clean up the code. You will also need to update the call sites for this function in `handleExport`. ```suggestion const performExport = async (host: SSHHost) => { ```
} }
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) => { const handleEdit = (host: SSHHost) => {
+3 -4
View File
@@ -434,10 +434,9 @@ export function Server({
<div className="text-xs text-gray-500"> <div className="text-xs text-gray-500">
{(() => { {(() => {
const used = metrics?.disk?.usedHuman; const available = metrics?.disk?.availableHuman;
const total = metrics?.disk?.totalHuman; return available
return used && total ? `Available: ${available}`
? `Available: ${total}`
: "Available: N/A"; : "Available: N/A";
})()} })()}
</div> </div>
@@ -532,6 +532,10 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
terminal.loadAddon(clipboardAddon); terminal.loadAddon(clipboardAddon);
terminal.loadAddon(unicode11Addon); terminal.loadAddon(unicode11Addon);
terminal.loadAddon(webLinksAddon); terminal.loadAddon(webLinksAddon);
// Activate Unicode 11 for proper emoji rendering (prevents double-width artifacts)
terminal.unicode.activeVersion = "11";
terminal.open(xtermRef.current); terminal.open(xtermRef.current);
const element = xtermRef.current; const element = xtermRef.current;
+4
View File
@@ -233,6 +233,10 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
terminal.loadAddon(clipboardAddon); terminal.loadAddon(clipboardAddon);
terminal.loadAddon(unicode11Addon); terminal.loadAddon(unicode11Addon);
terminal.loadAddon(webLinksAddon); terminal.loadAddon(webLinksAddon);
// Activate Unicode 11 for proper emoji rendering (prevents double-width artifacts)
terminal.unicode.activeVersion = "11";
terminal.open(xtermRef.current); terminal.open(xtermRef.current);
const textarea = xtermRef.current.querySelector( const textarea = xtermRef.current.querySelector(
+12
View File
@@ -51,6 +51,7 @@ interface DiskMetrics {
percent: number | null; percent: number | null;
usedHuman: string | null; usedHuman: string | null;
totalHuman: string | null; totalHuman: string | null;
availableHuman?: string | null; // Available disk space from df output
} }
export type ServerMetrics = { export type ServerMetrics = {
@@ -796,6 +797,17 @@ export async function getSSHHostById(hostId: number): Promise<SSHHost> {
} }
} }
export async function exportSSHHostWithCredentials(
hostId: number,
): Promise<SSHHost> {
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 // SSH AUTOSTART MANAGEMENT
// ============================================================================ // ============================================================================