v1.7.2 #364
@@ -10,6 +10,9 @@ http {
|
|||||||
keepalive_timeout 65;
|
keepalive_timeout 65;
|
||||||
client_header_timeout 300s;
|
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_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_ciphers ECDHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES128-SHA256:ECDHE-RSA-AES256-SHA384;
|
||||||
ssl_prefer_server_ciphers off;
|
ssl_prefer_server_ciphers off;
|
||||||
@@ -23,7 +26,6 @@ http {
|
|||||||
return 301 https://$host:${SSL_PORT}$request_uri;
|
return 301 https://$host:${SSL_PORT}$request_uri;
|
||||||
}
|
}
|
||||||
|
|
||||||
# HTTPS Server
|
|
||||||
server {
|
server {
|
||||||
listen ${SSL_PORT} ssl;
|
listen ${SSL_PORT} ssl;
|
||||||
server_name _;
|
server_name _;
|
||||||
@@ -41,7 +43,6 @@ http {
|
|||||||
index index.html index.htm;
|
index index.html index.htm;
|
||||||
}
|
}
|
||||||
|
|
||||||
# Handle missing source map files gracefully
|
|
||||||
location ~* \.map$ {
|
location ~* \.map$ {
|
||||||
return 404;
|
return 404;
|
||||||
access_log off;
|
access_log off;
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ http {
|
|||||||
keepalive_timeout 65;
|
keepalive_timeout 65;
|
||||||
client_header_timeout 300s;
|
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_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_ciphers ECDHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES128-SHA256:ECDHE-RSA-AES256-SHA384;
|
||||||
ssl_prefer_server_ciphers off;
|
ssl_prefer_server_ciphers off;
|
||||||
@@ -29,7 +32,6 @@ http {
|
|||||||
index index.html index.htm;
|
index index.html index.htm;
|
||||||
}
|
}
|
||||||
|
|
||||||
# Handle missing source map files gracefully
|
|
||||||
location ~* \.map$ {
|
location ~* \.map$ {
|
||||||
return 404;
|
return 404;
|
||||||
access_log off;
|
access_log off;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "termix",
|
"name": "termix",
|
||||||
"private": true,
|
"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",
|
"description": "A web-based server management platform with SSH terminal, tunneling, and file editing capabilities",
|
||||||
"author": "Karmaa",
|
"author": "Karmaa",
|
||||||
"main": "electron/main.cjs",
|
"main": "electron/main.cjs",
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ export const sshData = sqliteTable("ssh_data", {
|
|||||||
|
|
||||||
password: text("password"),
|
password: text("password"),
|
||||||
key: text("key", { length: 8192 }),
|
key: text("key", { length: 8192 }),
|
||||||
keyPassword: text("key_password"),
|
key_password: text("key_password"),
|
||||||
keyType: text("key_type"),
|
keyType: text("key_type"),
|
||||||
|
|
||||||
|
I see you're changing some field names from camelCase to snake_case (e.g., This mix of naming conventions can be confusing and lead to bugs. It would be best to choose one convention (preferably snake_case for database schemas) and apply it to all fields for better maintainability. 
I see you're changing some field names from camelCase to snake_case (e.g., `keyPassword` to `key_password`), which is great for consistency with typical SQL naming conventions. However, this change isn't applied consistently across the schema. For example, in the `sshData` table, fields like `userId`, `authType`, `credentialId`, `autostartPassword`, and `keyType` remain in camelCase. The `sshCredentials` table also has a mix of conventions (`keyType`, `detectedKeyType`, `usageCount`).
This mix of naming conventions can be confusing and lead to bugs. It would be best to choose one convention (preferably snake_case for database schemas) and apply it to all fields for better maintainability.
|
|||||||
autostartPassword: text("autostart_password"),
|
autostartPassword: text("autostart_password"),
|
||||||
@@ -142,9 +142,9 @@ export const sshCredentials = sqliteTable("ssh_credentials", {
|
|||||||
username: text("username").notNull(),
|
username: text("username").notNull(),
|
||||||
password: text("password"),
|
password: text("password"),
|
||||||
key: text("key", { length: 16384 }),
|
key: text("key", { length: 16384 }),
|
||||||
privateKey: text("private_key", { length: 16384 }),
|
private_key: text("private_key", { length: 16384 }),
|
||||||
publicKey: text("public_key", { length: 4096 }),
|
public_key: text("public_key", { length: 4096 }),
|
||||||
keyPassword: text("key_password"),
|
key_password: text("key_password"),
|
||||||
keyType: text("key_type"),
|
keyType: text("key_type"),
|
||||||
detectedKeyType: text("detected_key_type"),
|
detectedKeyType: text("detected_key_type"),
|
||||||
usageCount: integer("usage_count").notNull().default(0),
|
usageCount: integer("usage_count").notNull().default(0),
|
||||||
|
|||||||
@@ -174,9 +174,9 @@ router.post(
|
|||||||
username: username.trim(),
|
username: username.trim(),
|
||||||
password: plainPassword,
|
password: plainPassword,
|
||||||
key: plainKey,
|
key: plainKey,
|
||||||
privateKey: keyInfo?.privateKey || plainKey,
|
private_key: keyInfo?.privateKey || plainKey,
|
||||||
publicKey: keyInfo?.publicKey || null,
|
public_key: keyInfo?.publicKey || null,
|
||||||
keyPassword: plainKeyPassword,
|
key_password: plainKeyPassword,
|
||||||
keyType: keyType || null,
|
keyType: keyType || null,
|
||||||
detectedKeyType: keyInfo?.keyType || null,
|
detectedKeyType: keyInfo?.keyType || null,
|
||||||
usageCount: 0,
|
usageCount: 0,
|
||||||
@@ -424,13 +424,13 @@ router.put(
|
|||||||
error: `Invalid SSH key: ${keyInfo.error}`,
|
error: `Invalid SSH key: ${keyInfo.error}`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
updateFields.privateKey = keyInfo.privateKey;
|
updateFields.private_key = keyInfo.privateKey;
|
||||||
updateFields.publicKey = keyInfo.publicKey;
|
updateFields.public_key = keyInfo.publicKey;
|
||||||
updateFields.detectedKeyType = keyInfo.keyType;
|
updateFields.detectedKeyType = keyInfo.keyType;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (updateData.keyPassword !== undefined) {
|
if (updateData.keyPassword !== undefined) {
|
||||||
updateFields.keyPassword = updateData.keyPassword || null;
|
updateFields.key_password = updateData.keyPassword || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Object.keys(updateFields).length === 0) {
|
if (Object.keys(updateFields).length === 0) {
|
||||||
@@ -537,7 +537,7 @@ router.delete(
|
|||||||
credentialId: null,
|
credentialId: null,
|
||||||
password: null,
|
password: null,
|
||||||
key: null,
|
key: null,
|
||||||
keyPassword: null,
|
key_password: null,
|
||||||
authType: "password",
|
authType: "password",
|
||||||
})
|
})
|
||||||
.where(
|
.where(
|
||||||
@@ -633,7 +633,7 @@ router.post(
|
|||||||
authType: credential.auth_type || credential.authType,
|
authType: credential.auth_type || credential.authType,
|
||||||
password: null,
|
password: null,
|
||||||
key: null,
|
key: null,
|
||||||
keyPassword: null,
|
key_password: null,
|
||||||
keyType: null,
|
keyType: null,
|
||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ router.get("/db/host/internal", async (req: Request, res: Response) => {
|
|||||||
username: host.username,
|
username: host.username,
|
||||||
password: host.autostartPassword,
|
password: host.autostartPassword,
|
||||||
key: host.autostartKey,
|
key: host.autostartKey,
|
||||||
keyPassword: host.autostartKeyPassword,
|
key_password: host.autostartKeyPassword,
|
||||||
autostartPassword: host.autostartPassword,
|
autostartPassword: host.autostartPassword,
|
||||||
autostartKey: host.autostartKey,
|
autostartKey: host.autostartKey,
|
||||||
autostartKeyPassword: host.autostartKeyPassword,
|
autostartKeyPassword: host.autostartKeyPassword,
|
||||||
@@ -151,7 +151,7 @@ router.get("/db/host/internal/all", async (req: Request, res: Response) => {
|
|||||||
username: host.username,
|
username: host.username,
|
||||||
password: host.autostartPassword || host.password,
|
password: host.autostartPassword || host.password,
|
||||||
key: host.autostartKey || host.key,
|
key: host.autostartKey || host.key,
|
||||||
keyPassword: host.autostartKeyPassword || host.keyPassword,
|
key_password: host.autostartKeyPassword || host.key_password,
|
||||||
autostartPassword: host.autostartPassword,
|
autostartPassword: host.autostartPassword,
|
||||||
autostartKey: host.autostartKey,
|
autostartKey: host.autostartKey,
|
||||||
autostartKeyPassword: host.autostartKeyPassword,
|
autostartKeyPassword: host.autostartKeyPassword,
|
||||||
@@ -226,7 +226,7 @@ router.post(
|
|||||||
authType,
|
authType,
|
||||||
credentialId,
|
credentialId,
|
||||||
key,
|
key,
|
||||||
keyPassword,
|
key_password,
|
||||||
keyType,
|
keyType,
|
||||||
pin,
|
pin,
|
||||||
enableTerminal,
|
enableTerminal,
|
||||||
@@ -274,17 +274,17 @@ router.post(
|
|||||||
if (effectiveAuthType === "password") {
|
if (effectiveAuthType === "password") {
|
||||||
sshDataObj.password = password || null;
|
sshDataObj.password = password || null;
|
||||||
sshDataObj.key = null;
|
sshDataObj.key = null;
|
||||||
sshDataObj.keyPassword = null;
|
sshDataObj.key_password = null;
|
||||||
sshDataObj.keyType = null;
|
sshDataObj.keyType = null;
|
||||||
} else if (effectiveAuthType === "key") {
|
} else if (effectiveAuthType === "key") {
|
||||||
sshDataObj.key = key || null;
|
sshDataObj.key = key || null;
|
||||||
sshDataObj.keyPassword = keyPassword || null;
|
sshDataObj.key_password = key_password || null;
|
||||||
sshDataObj.keyType = keyType;
|
sshDataObj.keyType = keyType;
|
||||||
sshDataObj.password = null;
|
sshDataObj.password = null;
|
||||||
} else {
|
} else {
|
||||||
sshDataObj.password = null;
|
sshDataObj.password = null;
|
||||||
sshDataObj.key = null;
|
sshDataObj.key = null;
|
||||||
sshDataObj.keyPassword = null;
|
sshDataObj.key_password = null;
|
||||||
sshDataObj.keyType = null;
|
sshDataObj.keyType = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -407,7 +407,7 @@ router.put(
|
|||||||
authType,
|
authType,
|
||||||
credentialId,
|
credentialId,
|
||||||
key,
|
key,
|
||||||
keyPassword,
|
key_password,
|
||||||
keyType,
|
keyType,
|
||||||
pin,
|
pin,
|
||||||
enableTerminal,
|
enableTerminal,
|
||||||
@@ -458,14 +458,14 @@ router.put(
|
|||||||
sshDataObj.password = password;
|
sshDataObj.password = password;
|
||||||
}
|
}
|
||||||
sshDataObj.key = null;
|
sshDataObj.key = null;
|
||||||
sshDataObj.keyPassword = null;
|
sshDataObj.key_password = null;
|
||||||
sshDataObj.keyType = null;
|
sshDataObj.keyType = null;
|
||||||
} else if (effectiveAuthType === "key") {
|
} else if (effectiveAuthType === "key") {
|
||||||
if (key) {
|
if (key) {
|
||||||
sshDataObj.key = key;
|
sshDataObj.key = key;
|
||||||
}
|
}
|
||||||
if (keyPassword !== undefined) {
|
if (key_password !== undefined) {
|
||||||
sshDataObj.keyPassword = keyPassword || null;
|
sshDataObj.key_password = key_password || null;
|
||||||
}
|
}
|
||||||
if (keyType) {
|
if (keyType) {
|
||||||
sshDataObj.keyType = keyType;
|
sshDataObj.keyType = keyType;
|
||||||
@@ -474,7 +474,7 @@ router.put(
|
|||||||
} else {
|
} else {
|
||||||
sshDataObj.password = null;
|
sshDataObj.password = null;
|
||||||
sshDataObj.key = null;
|
sshDataObj.key = null;
|
||||||
sshDataObj.keyPassword = null;
|
sshDataObj.key_password = null;
|
||||||
sshDataObj.keyType = null;
|
sshDataObj.keyType = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -711,7 +711,7 @@ router.get(
|
|||||||
authType: resolvedHost.authType,
|
authType: resolvedHost.authType,
|
||||||
password: resolvedHost.password || null,
|
password: resolvedHost.password || null,
|
||||||
key: resolvedHost.key || null,
|
key: resolvedHost.key || null,
|
||||||
keyPassword: resolvedHost.keyPassword || null,
|
key_password: resolvedHost.key_password || null,
|
||||||
keyType: resolvedHost.keyType || null,
|
keyType: resolvedHost.keyType || null,
|
||||||
folder: resolvedHost.folder,
|
folder: resolvedHost.folder,
|
||||||
tags:
|
tags:
|
||||||
@@ -1234,7 +1234,7 @@ async function resolveHostCredentials(host: any): Promise<any> {
|
|||||||
authType: credential.auth_type || credential.authType,
|
authType: credential.auth_type || credential.authType,
|
||||||
password: credential.password,
|
password: credential.password,
|
||||||
key: credential.key,
|
key: credential.key,
|
||||||
keyPassword: credential.key_password || credential.keyPassword,
|
key_password: credential.key_password || credential.key_password,
|
||||||
keyType: credential.key_type || credential.keyType,
|
keyType: credential.key_type || credential.keyType,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -1404,8 +1404,8 @@ router.post(
|
|||||||
credentialId:
|
credentialId:
|
||||||
hostData.authType === "credential" ? hostData.credentialId : null,
|
hostData.authType === "credential" ? hostData.credentialId : null,
|
||||||
key: hostData.authType === "key" ? hostData.key : null,
|
key: hostData.authType === "key" ? hostData.key : null,
|
||||||
keyPassword:
|
key_password:
|
||||||
hostData.authType === "key" ? hostData.keyPassword : null,
|
hostData.authType === "key" ? hostData.key_password : null,
|
||||||
keyType:
|
keyType:
|
||||||
hostData.authType === "key" ? hostData.keyType || "auto" : null,
|
hostData.authType === "key" ? hostData.keyType || "auto" : null,
|
||||||
pin: hostData.pin || false,
|
pin: hostData.pin || false,
|
||||||
@@ -1540,7 +1540,7 @@ router.post(
|
|||||||
...tunnel,
|
...tunnel,
|
||||||
endpointPassword: decryptedEndpoint.password || null,
|
endpointPassword: decryptedEndpoint.password || null,
|
||||||
endpointKey: decryptedEndpoint.key || null,
|
endpointKey: decryptedEndpoint.key || null,
|
||||||
endpointKeyPassword: decryptedEndpoint.keyPassword || null,
|
endpointKeyPassword: decryptedEndpoint.key_password || null,
|
||||||
endpointAuthType: endpointHost.authType,
|
endpointAuthType: endpointHost.authType,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -1563,7 +1563,7 @@ router.post(
|
|||||||
.set({
|
.set({
|
||||||
autostartPassword: decryptedConfig.password || null,
|
autostartPassword: decryptedConfig.password || null,
|
||||||
autostartKey: decryptedConfig.key || null,
|
autostartKey: decryptedConfig.key || null,
|
||||||
autostartKeyPassword: decryptedConfig.keyPassword || null,
|
autostartKeyPassword: decryptedConfig.key_password || null,
|
||||||
tunnelConnections: updatedTunnelConnections,
|
tunnelConnections: updatedTunnelConnections,
|
||||||
})
|
})
|
||||||
.where(eq(sshData.id, sshConfigId));
|
.where(eq(sshData.id, sshConfigId));
|
||||||
|
|||||||
@@ -6,6 +6,20 @@ export class LazyFieldEncryption {
|
|||||||
key_password: "keyPassword",
|
key_password: "keyPassword",
|
||||||
private_key: "privateKey",
|
private_key: "privateKey",
|
||||||
public_key: "publicKey",
|
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 {
|
static isPlaintextField(value: string): boolean {
|
||||||
|
|||||||
@@ -70,7 +70,36 @@ class UserCrypto {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async setupOIDCUserEncryption(userId: string): Promise<void> {
|
async setupOIDCUserEncryption(userId: string): Promise<void> {
|
||||||
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();
|
const now = Date.now();
|
||||||
this.userSessions.set(userId, {
|
this.userSessions.set(userId, {
|
||||||
@@ -134,20 +163,14 @@ class UserCrypto {
|
|||||||
|
|
||||||
async authenticateOIDCUser(userId: string): Promise<boolean> {
|
async authenticateOIDCUser(userId: string): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
const kekSalt = await this.getKEKSalt(userId);
|
const encryptedDEK = await this.getEncryptedDEK(userId);
|
||||||
if (!kekSalt) {
|
|
||||||
|
if (!encryptedDEK) {
|
||||||
await this.setupOIDCUserEncryption(userId);
|
await this.setupOIDCUserEncryption(userId);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const systemKey = this.deriveOIDCSystemKey(userId);
|
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);
|
const DEK = this.decryptDEK(encryptedDEK, systemKey);
|
||||||
systemKey.fill(0);
|
systemKey.fill(0);
|
||||||
|
|
||||||
|
|||||||
@@ -844,8 +844,13 @@
|
|||||||
"selectServerToEdit": "Select a server from the sidebar to start editing files",
|
"selectServerToEdit": "Select a server from the sidebar to start editing files",
|
||||||
"fileOperations": "File Operations",
|
"fileOperations": "File Operations",
|
||||||
"confirmDeleteMessage": "Are you sure you want to delete <strong>{{name}}</strong>?",
|
"confirmDeleteMessage": "Are you sure you want to delete <strong>{{name}}</strong>?",
|
||||||
|
"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.",
|
"deleteDirectoryWarning": "This will delete the folder and all its contents.",
|
||||||
"actionCannotBeUndone": "This action cannot be undone.",
|
"actionCannotBeUndone": "This action cannot be undone.",
|
||||||
|
"permanentDeleteWarning": "This action cannot be undone. The item(s) will be permanently deleted from the server.",
|
||||||
"recent": "Recent",
|
"recent": "Recent",
|
||||||
"pinned": "Pinned",
|
"pinned": "Pinned",
|
||||||
"folderShortcuts": "Folder Shortcuts",
|
"folderShortcuts": "Folder Shortcuts",
|
||||||
|
|||||||
@@ -852,8 +852,13 @@
|
|||||||
"selectServerToEdit": "从侧边栏选择服务器以开始编辑文件",
|
"selectServerToEdit": "从侧边栏选择服务器以开始编辑文件",
|
||||||
"fileOperations": "文件操作",
|
"fileOperations": "文件操作",
|
||||||
"confirmDeleteMessage": "确定要删除 <strong>{{name}}</strong> 吗?",
|
"confirmDeleteMessage": "确定要删除 <strong>{{name}}</strong> 吗?",
|
||||||
|
"confirmDeleteSingleItem": "确定要永久删除 \"{{name}}\" 吗?",
|
||||||
|
"confirmDeleteMultipleItems": "确定要永久删除 {{count}} 个项目吗?",
|
||||||
|
"confirmDeleteMultipleItemsWithFolders": "确定要永久删除 {{count}} 个项目吗?这包括文件夹及其内容。",
|
||||||
|
"confirmDeleteFolder": "确定要永久删除文件夹 \"{{name}}\" 及其所有内容吗?",
|
||||||
"deleteDirectoryWarning": "这将删除文件夹及其所有内容。",
|
"deleteDirectoryWarning": "这将删除文件夹及其所有内容。",
|
||||||
"actionCannotBeUndone": "此操作无法撤销。",
|
"actionCannotBeUndone": "此操作无法撤销。",
|
||||||
|
"permanentDeleteWarning": "此操作无法撤销。项目将从服务器永久删除。",
|
||||||
"dragSystemFilesToUpload": "拖拽系统文件到此处上传",
|
"dragSystemFilesToUpload": "拖拽系统文件到此处上传",
|
||||||
"dragFilesToWindowToDownload": "拖拽文件到窗口外下载",
|
"dragFilesToWindowToDownload": "拖拽文件到窗口外下载",
|
||||||
"openTerminalHere": "在此处打开终端",
|
"openTerminalHere": "在此处打开终端",
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { FileWindow } from "./components/FileWindow";
|
|||||||
import { DiffWindow } from "./components/DiffWindow";
|
import { DiffWindow } from "./components/DiffWindow";
|
||||||
import { useDragToDesktop } from "../../../hooks/useDragToDesktop";
|
import { useDragToDesktop } from "../../../hooks/useDragToDesktop";
|
||||||
import { useDragToSystemDesktop } from "../../../hooks/useDragToSystemDesktop";
|
import { useDragToSystemDesktop } from "../../../hooks/useDragToSystemDesktop";
|
||||||
|
import { useConfirmation } from "@/hooks/use-confirmation.ts";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@@ -82,6 +83,7 @@ function formatFileSize(bytes?: number): string {
|
|||||||
function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
||||||
const { openWindow } = useWindowManager();
|
const { openWindow } = useWindowManager();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const { confirmWithToast } = useConfirmation();
|
||||||
|
|
||||||
const [currentHost, setCurrentHost] = useState<SSHHost | null>(
|
const [currentHost, setCurrentHost] = useState<SSHHost | null>(
|
||||||
initialHost || null,
|
initialHost || null,
|
||||||
@@ -587,54 +589,85 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
|||||||
async function handleDeleteFiles(files: FileItem[]) {
|
async function handleDeleteFiles(files: FileItem[]) {
|
||||||
if (!sshSessionId || files.length === 0) return;
|
if (!sshSessionId || files.length === 0) return;
|
||||||
|
|
||||||
try {
|
let confirmMessage: string;
|
||||||
await ensureSSHConnection();
|
if (files.length === 1) {
|
||||||
|
const file = files[0];
|
||||||
for (const file of files) {
|
if (file.type === "directory") {
|
||||||
await deleteSSHItem(
|
confirmMessage = t("fileManager.confirmDeleteFolder", {
|
||||||
sshSessionId,
|
name: file.name,
|
||||||
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 {
|
} 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() {
|
function handleCreateNewFolder() {
|
||||||
|
|||||||
@@ -210,7 +210,18 @@ export function HostManagerEditor({
|
|||||||
defaultPath: z.string().optional(),
|
defaultPath: z.string().optional(),
|
||||||
})
|
})
|
||||||
.superRefine((data, ctx) => {
|
.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 (
|
if (
|
||||||
!data.key ||
|
!data.key ||
|
||||||
(typeof data.key === "string" && data.key.trim() === "")
|
(typeof data.key === "string" && data.key.trim() === "")
|
||||||
|
|||||||
@@ -24,7 +24,10 @@ function AppContent() {
|
|||||||
const [isAdmin, setIsAdmin] = useState(false);
|
const [isAdmin, setIsAdmin] = useState(false);
|
||||||
const [authLoading, setAuthLoading] = useState(true);
|
const [authLoading, setAuthLoading] = useState(true);
|
||||||
const [showVersionCheck, setShowVersionCheck] = useState(true);
|
const [showVersionCheck, setShowVersionCheck] = useState(true);
|
||||||
const [isTopbarOpen, setIsTopbarOpen] = useState<boolean>(true);
|
const [isTopbarOpen, setIsTopbarOpen] = useState<boolean>(() => {
|
||||||
|
const saved = localStorage.getItem("topNavbarOpen");
|
||||||
|
return saved !== null ? JSON.parse(saved) : true;
|
||||||
|
});
|
||||||
const { currentTab, tabs } = useTabs();
|
const { currentTab, tabs } = useTabs();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -64,6 +67,10 @@ function AppContent() {
|
|||||||
return () => window.removeEventListener("storage", handleStorageChange);
|
return () => window.removeEventListener("storage", handleStorageChange);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
localStorage.setItem("topNavbarOpen", JSON.stringify(isTopbarOpen));
|
||||||
|
}, [isTopbarOpen]);
|
||||||
|
|
||||||
const handleSelectView = (nextView: string) => {
|
const handleSelectView = (nextView: string) => {
|
||||||
setMountedViews((prev) => {
|
setMountedViews((prev) => {
|
||||||
if (prev.has(nextView)) return prev;
|
if (prev.has(nextView)) return prev;
|
||||||
|
|||||||
@@ -101,7 +101,10 @@ export function LeftSidebar({
|
|||||||
const [deleteLoading, setDeleteLoading] = React.useState(false);
|
const [deleteLoading, setDeleteLoading] = React.useState(false);
|
||||||
const [deleteError, setDeleteError] = React.useState<string | null>(null);
|
const [deleteError, setDeleteError] = React.useState<string | null>(null);
|
||||||
|
|
||||||
const [isSidebarOpen, setIsSidebarOpen] = useState<boolean>(true);
|
const [isSidebarOpen, setIsSidebarOpen] = useState<boolean>(() => {
|
||||||
|
const saved = localStorage.getItem("leftSidebarOpen");
|
||||||
|
return saved !== null ? JSON.parse(saved) : true;
|
||||||
|
});
|
||||||
|
|
||||||
const {
|
const {
|
||||||
tabs: tabList,
|
tabs: tabList,
|
||||||
@@ -181,7 +184,6 @@ export function LeftSidebar({
|
|||||||
newHost.key !== existingHost.key ||
|
newHost.key !== existingHost.key ||
|
||||||
newHost.keyPassword !== existingHost.keyPassword ||
|
newHost.keyPassword !== existingHost.keyPassword ||
|
||||||
newHost.keyType !== existingHost.keyType ||
|
newHost.keyType !== existingHost.keyType ||
|
||||||
newHost.credentialId !== existingHost.credentialId ||
|
|
||||||
newHost.defaultPath !== existingHost.defaultPath ||
|
newHost.defaultPath !== existingHost.defaultPath ||
|
||||||
JSON.stringify(newHost.tags) !==
|
JSON.stringify(newHost.tags) !==
|
||||||
JSON.stringify(existingHost.tags) ||
|
JSON.stringify(existingHost.tags) ||
|
||||||
@@ -247,6 +249,10 @@ export function LeftSidebar({
|
|||||||
return () => clearTimeout(handler);
|
return () => clearTimeout(handler);
|
||||||
}, [search]);
|
}, [search]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
localStorage.setItem("leftSidebarOpen", JSON.stringify(isSidebarOpen));
|
||||||
|
}, [isSidebarOpen]);
|
||||||
|
|
||||||
const filteredHosts = React.useMemo(() => {
|
const filteredHosts = React.useMemo(() => {
|
||||||
if (!debouncedSearch.trim()) return hosts;
|
if (!debouncedSearch.trim()) return hosts;
|
||||||
const q = debouncedSearch.trim().toLowerCase();
|
const q = debouncedSearch.trim().toLowerCase();
|
||||||
|
|||||||
Reference in New Issue
Block a user
The
set_real_ip_from 127.0.0.1;directive is quite restrictive. If this NGINX instance is running inside a Docker container and is fronted by another proxy (e.g., another container in the same Docker network), the request will likely come from an internal Docker IP, not127.0.0.1. This would result in the real client IP not being correctly identified.To make this more robust, consider trusting the common Docker network ranges. This will cover most containerized and local proxy setups. You might want to adjust the CIDR blocks based on your specific Docker network configuration.