fix: Merge zac/luke

This commit is contained in:
ZacharyZcR
2025-11-10 10:41:20 +08:00
committed by LukeGus
parent 45176bc735
commit 16e35d7976
30 changed files with 5976 additions and 668 deletions

View File

@@ -7,6 +7,7 @@ import sshRoutes from "./routes/ssh.js";
import alertRoutes from "./routes/alerts.js";
import credentialsRoutes from "./routes/credentials.js";
import snippetsRoutes from "./routes/snippets.js";
import terminalRoutes from "./routes/terminal.js";
import cors from "cors";
import fetch from "node-fetch";
import fs from "fs";
@@ -1418,6 +1419,7 @@ app.use("/ssh", sshRoutes);
app.use("/alerts", alertRoutes);
app.use("/credentials", credentialsRoutes);
app.use("/snippets", snippetsRoutes);
app.use("/terminal", terminalRoutes);
app.use(
(

View File

@@ -300,6 +300,17 @@ async function initializeCompleteDatabase(): Promise<void> {
FOREIGN KEY (user_id) REFERENCES users (id)
);
CREATE TABLE IF NOT EXISTS ssh_folders (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
name TEXT NOT NULL,
color TEXT,
icon TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users (id)
);
CREATE TABLE IF NOT EXISTS recent_activity (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
@@ -311,6 +322,16 @@ async function initializeCompleteDatabase(): Promise<void> {
FOREIGN KEY (host_id) REFERENCES ssh_data (id)
);
CREATE TABLE IF NOT EXISTS command_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
host_id INTEGER NOT NULL,
command TEXT NOT NULL,
executed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users (id),
FOREIGN KEY (host_id) REFERENCES ssh_data (id)
);
`);
try {

View File

@@ -212,6 +212,22 @@ export const snippets = sqliteTable("snippets", {
.default(sql`CURRENT_TIMESTAMP`),
});
export const sshFolders = sqliteTable("ssh_folders", {
id: integer("id").primaryKey({ autoIncrement: true }),
userId: text("user_id")
.notNull()
.references(() => users.id),
name: text("name").notNull(),
color: text("color"),
icon: text("icon"),
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
updatedAt: text("updated_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
});
export const recentActivity = sqliteTable("recent_activity", {
id: integer("id").primaryKey({ autoIncrement: true }),
userId: text("user_id")
@@ -226,3 +242,17 @@ export const recentActivity = sqliteTable("recent_activity", {
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
});
export const commandHistory = sqliteTable("command_history", {
id: integer("id").primaryKey({ autoIncrement: true }),
userId: text("user_id")
.notNull()
.references(() => users.id),
hostId: integer("host_id")
.notNull()
.references(() => sshData.id),
command: text("command").notNull(),
executedAt: text("executed_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
});

View File

@@ -8,7 +8,7 @@ import {
fileManagerRecent,
fileManagerPinned,
fileManagerShortcuts,
recentActivity,
sshFolders,
} from "../db/schema.js";
import { eq, and, desc, isNotNull, or } from "drizzle-orm";
import type { Request, Response } from "express";
@@ -226,7 +226,6 @@ router.post(
authMethod,
authType,
credentialId,
overrideCredentialUsername,
key,
keyPassword,
keyType,
@@ -266,7 +265,6 @@ router.post(
username,
authType: effectiveAuthType,
credentialId: credentialId || null,
overrideCredentialUsername: overrideCredentialUsername ? 1 : 0,
pin: pin ? 1 : 0,
enableTerminal: enableTerminal ? 1 : 0,
enableTunnel: enableTunnel ? 1 : 0,
@@ -326,7 +324,6 @@ router.post(
: []
: [],
pin: !!createdHost.pin,
overrideCredentialUsername: !!createdHost.overrideCredentialUsername,
enableTerminal: !!createdHost.enableTerminal,
enableTunnel: !!createdHost.enableTunnel,
tunnelConnections: createdHost.tunnelConnections
@@ -353,27 +350,6 @@ router.post(
},
);
try {
const fetch = (await import("node-fetch")).default;
const token =
req.cookies?.jwt || req.headers.authorization?.replace("Bearer ", "");
await fetch("http://localhost:30005/refresh", {
method: "POST",
headers: {
"Content-Type": "application/json",
...(token && { Cookie: `jwt=${token}` }),
},
});
} catch (refreshError) {
sshLogger.warn("Failed to refresh server stats polling", {
operation: "stats_refresh_after_create",
error:
refreshError instanceof Error
? refreshError.message
: "Unknown error",
});
}
res.json(resolvedHost);
} catch (err) {
sshLogger.error("Failed to save SSH host to database", err, {
@@ -440,7 +416,6 @@ router.put(
authMethod,
authType,
credentialId,
overrideCredentialUsername,
key,
keyPassword,
keyType,
@@ -481,7 +456,6 @@ router.put(
username,
authType: effectiveAuthType,
credentialId: credentialId || null,
overrideCredentialUsername: overrideCredentialUsername ? 1 : 0,
pin: pin ? 1 : 0,
enableTerminal: enableTerminal ? 1 : 0,
enableTunnel: enableTunnel ? 1 : 0,
@@ -559,7 +533,6 @@ router.put(
: []
: [],
pin: !!updatedHost.pin,
overrideCredentialUsername: !!updatedHost.overrideCredentialUsername,
enableTerminal: !!updatedHost.enableTerminal,
enableTunnel: !!updatedHost.enableTunnel,
tunnelConnections: updatedHost.tunnelConnections
@@ -586,27 +559,6 @@ router.put(
},
);
try {
const fetch = (await import("node-fetch")).default;
const token =
req.cookies?.jwt || req.headers.authorization?.replace("Bearer ", "");
await fetch("http://localhost:30005/refresh", {
method: "POST",
headers: {
"Content-Type": "application/json",
...(token && { Cookie: `jwt=${token}` }),
},
});
} catch (refreshError) {
sshLogger.warn("Failed to refresh server stats polling", {
operation: "stats_refresh_after_update",
error:
refreshError instanceof Error
? refreshError.message
: "Unknown error",
});
}
res.json(resolvedHost);
} catch (err) {
sshLogger.error("Failed to update SSH host in database", err, {
@@ -634,18 +586,6 @@ router.get("/db/host", authenticateJWT, async (req: Request, res: Response) => {
});
return res.status(400).json({ error: "Invalid userId" });
}
if (!SimpleDBOps.isUserDataUnlocked(userId)) {
sshLogger.warn("User data not unlocked for SSH host fetch", {
operation: "host_fetch",
userId,
});
return res.status(401).json({
error: "Session expired - please log in again",
code: "SESSION_EXPIRED",
});
}
try {
const data = await SimpleDBOps.select(
db.select().from(sshData).where(eq(sshData.userId, userId)),
@@ -664,7 +604,6 @@ router.get("/db/host", authenticateJWT, async (req: Request, res: Response) => {
: []
: [],
pin: !!row.pin,
overrideCredentialUsername: !!row.overrideCredentialUsername,
enableTerminal: !!row.enableTerminal,
enableTunnel: !!row.enableTunnel,
tunnelConnections: row.tunnelConnections
@@ -711,19 +650,6 @@ router.get(
});
return res.status(400).json({ error: "Invalid userId or hostId" });
}
if (!SimpleDBOps.isUserDataUnlocked(userId)) {
sshLogger.warn("User data not unlocked for SSH host fetch by ID", {
operation: "host_fetch_by_id",
hostId: parseInt(hostId),
userId,
});
return res.status(401).json({
error: "Session expired - please log in again",
code: "SESSION_EXPIRED",
});
}
try {
const data = await db
.select()
@@ -749,7 +675,6 @@ router.get(
: []
: [],
pin: !!host.pin,
overrideCredentialUsername: !!host.overrideCredentialUsername,
enableTerminal: !!host.enableTerminal,
enableTunnel: !!host.enableTunnel,
tunnelConnections: host.tunnelConnections
@@ -924,15 +849,6 @@ router.delete(
),
);
await db
.delete(recentActivity)
.where(
and(
eq(recentActivity.userId, userId),
eq(recentActivity.hostId, numericHostId),
),
);
await db
.delete(sshData)
.where(and(eq(sshData.id, numericHostId), eq(sshData.userId, userId)));
@@ -1352,9 +1268,7 @@ async function resolveHostCredentials(
const credential = credentials[0];
return {
...host,
username: host.overrideCredentialUsername
? host.username
: credential.username,
username: credential.username,
authType: credential.auth_type || credential.authType,
password: credential.password,
key: credential.key,
@@ -1428,6 +1342,17 @@ router.put(
DatabaseSaveTrigger.triggerSave("folder_rename");
// Also update folder metadata if exists
await db
.update(sshFolders)
.set({
name: newName,
updatedAt: new Date().toISOString(),
})
.where(
and(eq(sshFolders.userId, userId), eq(sshFolders.name, oldName)),
);
res.json({
message: "Folder renamed successfully",
updatedHosts: updatedHosts.length,
@@ -1445,6 +1370,151 @@ router.put(
},
);
// Route: Get all folders with metadata (requires JWT)
// GET /ssh/db/folders
router.get("/folders", authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
if (!isNonEmptyString(userId)) {
return res.status(400).json({ error: "Invalid user ID" });
}
try {
const folders = await db
.select()
.from(sshFolders)
.where(eq(sshFolders.userId, userId));
res.json(folders);
} catch (err) {
sshLogger.error("Failed to fetch folders", err, {
operation: "fetch_folders",
userId,
});
res.status(500).json({ error: "Failed to fetch folders" });
}
});
// Route: Update folder metadata (requires JWT)
// PUT /ssh/db/folders/metadata
router.put(
"/folders/metadata",
authenticateJWT,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const { name, color, icon } = req.body;
if (!isNonEmptyString(userId) || !name) {
return res.status(400).json({ error: "Folder name is required" });
}
try {
// Check if folder metadata exists
const existing = await db
.select()
.from(sshFolders)
.where(and(eq(sshFolders.userId, userId), eq(sshFolders.name, name)))
.limit(1);
if (existing.length > 0) {
// Update existing
await db
.update(sshFolders)
.set({
color,
icon,
updatedAt: new Date().toISOString(),
})
.where(and(eq(sshFolders.userId, userId), eq(sshFolders.name, name)));
} else {
// Create new
await db.insert(sshFolders).values({
userId,
name,
color,
icon,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
}
DatabaseSaveTrigger.triggerSave("folder_metadata_update");
res.json({ message: "Folder metadata updated successfully" });
} catch (err) {
sshLogger.error("Failed to update folder metadata", err, {
operation: "update_folder_metadata",
userId,
name,
});
res.status(500).json({ error: "Failed to update folder metadata" });
}
},
);
// Route: Delete all hosts in folder (requires JWT)
// DELETE /ssh/db/folders/:name/hosts
router.delete(
"/folders/:name/hosts",
authenticateJWT,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const folderName = req.params.name;
if (!isNonEmptyString(userId) || !folderName) {
return res.status(400).json({ error: "Invalid folder name" });
}
try {
// Get all hosts in the folder
const hostsToDelete = await db
.select()
.from(sshData)
.where(and(eq(sshData.userId, userId), eq(sshData.folder, folderName)));
if (hostsToDelete.length === 0) {
return res.json({
message: "No hosts found in folder",
deletedCount: 0,
});
}
// Delete all hosts
await db
.delete(sshData)
.where(and(eq(sshData.userId, userId), eq(sshData.folder, folderName)));
// Delete folder metadata
await db
.delete(sshFolders)
.where(
and(eq(sshFolders.userId, userId), eq(sshFolders.name, folderName)),
);
DatabaseSaveTrigger.triggerSave("folder_hosts_delete");
sshLogger.info("Deleted all hosts in folder", {
operation: "delete_folder_hosts",
userId,
folderName,
deletedCount: hostsToDelete.length,
});
res.json({
message: "All hosts in folder deleted successfully",
deletedCount: hostsToDelete.length,
});
} catch (err) {
sshLogger.error("Failed to delete hosts in folder", err, {
operation: "delete_folder_hosts",
userId,
folderName,
});
res.status(500).json({ error: "Failed to delete hosts in folder" });
}
},
);
// Route: Bulk import SSH hosts (requires JWT)
// POST /ssh/bulk-import
router.post(
@@ -1533,10 +1603,8 @@ router.post(
username: hostData.username,
password: hostData.authType === "password" ? hostData.password : null,
authType: hostData.authType,
credentialId: hostData.credentialId || null,
overrideCredentialUsername: hostData.overrideCredentialUsername
? 1
: 0,
credentialId:
hostData.authType === "credential" ? hostData.credentialId : null,
key: hostData.authType === "key" ? hostData.key : null,
keyPassword:
hostData.authType === "key"

View File

@@ -0,0 +1,214 @@
import type { AuthenticatedRequest } from "../../../types/index.js";
import express from "express";
import { db } from "../db/index.js";
import { commandHistory } from "../db/schema.js";
import { eq, and, desc, sql } from "drizzle-orm";
import type { Request, Response } from "express";
import { authLogger } from "../../utils/logger.js";
import { AuthManager } from "../../utils/auth-manager.js";
const router = express.Router();
function isNonEmptyString(val: unknown): val is string {
return typeof val === "string" && val.trim().length > 0;
}
const authManager = AuthManager.getInstance();
const authenticateJWT = authManager.createAuthMiddleware();
const requireDataAccess = authManager.createDataAccessMiddleware();
// Save command to history
// POST /terminal/command_history
router.post(
"/command_history",
authenticateJWT,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const { hostId, command } = req.body;
if (!isNonEmptyString(userId) || !hostId || !isNonEmptyString(command)) {
authLogger.warn("Invalid command history save request", {
operation: "command_history_save",
userId,
hasHostId: !!hostId,
hasCommand: !!command,
});
return res.status(400).json({ error: "Missing required parameters" });
}
try {
const insertData = {
userId,
hostId: parseInt(hostId, 10),
command: command.trim(),
};
const result = await db
.insert(commandHistory)
.values(insertData)
.returning();
authLogger.info(`Command saved to history for host ${hostId}`, {
operation: "command_history_save_success",
userId,
hostId: parseInt(hostId, 10),
});
res.status(201).json(result[0]);
} catch (err) {
authLogger.error("Failed to save command to history", err);
res.status(500).json({
error: err instanceof Error ? err.message : "Failed to save command",
});
}
},
);
// Get command history for a specific host
// GET /terminal/command_history/:hostId
router.get(
"/command_history/:hostId",
authenticateJWT,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const { hostId } = req.params;
const hostIdNum = parseInt(hostId, 10);
if (!isNonEmptyString(userId) || isNaN(hostIdNum)) {
authLogger.warn("Invalid command history fetch request", {
userId,
hostId: hostIdNum,
});
return res.status(400).json({ error: "Invalid request parameters" });
}
try {
// Get unique commands for this host, ordered by most recent
// Use DISTINCT to avoid duplicates, but keep the most recent occurrence
const result = await db
.selectDistinct({ command: commandHistory.command })
.from(commandHistory)
.where(
and(
eq(commandHistory.userId, userId),
eq(commandHistory.hostId, hostIdNum),
),
)
.orderBy(desc(commandHistory.executedAt))
.limit(500); // Limit to last 500 unique commands
// Further deduplicate in case DISTINCT didn't work perfectly
const uniqueCommands = Array.from(new Set(result.map((r) => r.command)));
authLogger.info(`Fetched command history for host ${hostId}`, {
operation: "command_history_fetch_success",
userId,
hostId: hostIdNum,
count: uniqueCommands.length,
});
res.json(uniqueCommands);
} catch (err) {
authLogger.error("Failed to fetch command history", err);
res.status(500).json({
error: err instanceof Error ? err.message : "Failed to fetch history",
});
}
},
);
// Delete a specific command from history
// POST /terminal/command_history/delete
router.post(
"/command_history/delete",
authenticateJWT,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const { hostId, command } = req.body;
if (!isNonEmptyString(userId) || !hostId || !isNonEmptyString(command)) {
authLogger.warn("Invalid command delete request", {
operation: "command_history_delete",
userId,
hasHostId: !!hostId,
hasCommand: !!command,
});
return res.status(400).json({ error: "Missing required parameters" });
}
try {
const hostIdNum = parseInt(hostId, 10);
// Delete all instances of this command for this user and host
await db
.delete(commandHistory)
.where(
and(
eq(commandHistory.userId, userId),
eq(commandHistory.hostId, hostIdNum),
eq(commandHistory.command, command.trim()),
),
);
authLogger.info(`Command deleted from history for host ${hostId}`, {
operation: "command_history_delete_success",
userId,
hostId: hostIdNum,
});
res.json({ success: true });
} catch (err) {
authLogger.error("Failed to delete command from history", err);
res.status(500).json({
error: err instanceof Error ? err.message : "Failed to delete command",
});
}
},
);
// Clear command history for a specific host (optional feature)
// DELETE /terminal/command_history/:hostId
router.delete(
"/command_history/:hostId",
authenticateJWT,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const { hostId } = req.params;
const hostIdNum = parseInt(hostId, 10);
if (!isNonEmptyString(userId) || isNaN(hostIdNum)) {
authLogger.warn("Invalid command history clear request");
return res.status(400).json({ error: "Invalid request" });
}
try {
await db
.delete(commandHistory)
.where(
and(
eq(commandHistory.userId, userId),
eq(commandHistory.hostId, hostIdNum),
),
);
authLogger.success(`Command history cleared for host ${hostId}`, {
operation: "command_history_clear_success",
userId,
hostId: hostIdNum,
});
res.json({ success: true });
} catch (err) {
authLogger.error("Failed to clear command history", err);
res.status(500).json({
error: err instanceof Error ? err.message : "Failed to clear history",
});
}
},
);
export default router;

View File

@@ -2490,6 +2490,474 @@ app.post("/ssh/file_manager/ssh/executeFile", async (req, res) => {
});
});
app.post("/ssh/file_manager/ssh/changePermissions", async (req, res) => {
const { sessionId, path, permissions } = req.body;
const sshConn = sshSessions[sessionId];
if (!sshConn || !sshConn.isConnected) {
fileLogger.error(
"SSH connection not found or not connected for changePermissions",
{
operation: "change_permissions",
sessionId,
hasConnection: !!sshConn,
isConnected: sshConn?.isConnected,
},
);
return res.status(400).json({ error: "SSH connection not available" });
}
if (!path) {
return res.status(400).json({ error: "File path is required" });
}
if (!permissions || !/^\d{3,4}$/.test(permissions)) {
return res.status(400).json({
error: "Valid permissions required (e.g., 755, 644)",
});
}
const octalPerms = permissions.slice(-3);
const escapedPath = path.replace(/'/g, "'\"'\"'");
const command = `chmod ${octalPerms} '${escapedPath}'`;
fileLogger.info("Changing file permissions", {
operation: "change_permissions",
sessionId,
path,
permissions: octalPerms,
});
sshConn.client.exec(command, (err, stream) => {
if (err) {
fileLogger.error("SSH changePermissions exec error:", err, {
operation: "change_permissions",
sessionId,
path,
permissions: octalPerms,
});
return res.status(500).json({ error: "Failed to change permissions" });
}
let errorOutput = "";
stream.stderr.on("data", (data) => {
errorOutput += data.toString();
});
stream.on("close", (code) => {
if (code !== 0) {
fileLogger.error("chmod command failed", {
operation: "change_permissions",
sessionId,
path,
permissions: octalPerms,
exitCode: code,
error: errorOutput,
});
return res.status(500).json({
error: errorOutput || "Failed to change permissions",
});
}
fileLogger.success("File permissions changed successfully", {
operation: "change_permissions",
sessionId,
path,
permissions: octalPerms,
});
res.json({
success: true,
message: "Permissions changed successfully",
});
});
stream.on("error", (streamErr) => {
fileLogger.error("SSH changePermissions stream error:", streamErr, {
operation: "change_permissions",
sessionId,
path,
permissions: octalPerms,
});
if (!res.headersSent) {
res
.status(500)
.json({ error: "Stream error while changing permissions" });
}
});
});
});
// Route: Extract archive file (requires JWT)
// POST /ssh/file_manager/ssh/extractArchive
app.post("/ssh/file_manager/ssh/extractArchive", async (req, res) => {
const { sessionId, archivePath, extractPath } = req.body;
if (!sessionId || !archivePath) {
return res.status(400).json({ error: "Missing required parameters" });
}
const session = sshSessions[sessionId];
if (!session || !session.isConnected) {
return res.status(400).json({ error: "SSH session not connected" });
}
session.lastActive = Date.now();
scheduleSessionCleanup(sessionId);
const fileName = archivePath.split("/").pop() || "";
const fileExt = fileName.toLowerCase();
// Determine extraction command based on file extension
let extractCommand = "";
const targetPath =
extractPath || archivePath.substring(0, archivePath.lastIndexOf("/"));
if (fileExt.endsWith(".tar.gz") || fileExt.endsWith(".tgz")) {
extractCommand = `tar -xzf "${archivePath}" -C "${targetPath}"`;
} else if (fileExt.endsWith(".tar.bz2") || fileExt.endsWith(".tbz2")) {
extractCommand = `tar -xjf "${archivePath}" -C "${targetPath}"`;
} else if (fileExt.endsWith(".tar.xz")) {
extractCommand = `tar -xJf "${archivePath}" -C "${targetPath}"`;
} else if (fileExt.endsWith(".tar")) {
extractCommand = `tar -xf "${archivePath}" -C "${targetPath}"`;
} else if (fileExt.endsWith(".zip")) {
extractCommand = `unzip -o "${archivePath}" -d "${targetPath}"`;
} else if (fileExt.endsWith(".gz") && !fileExt.endsWith(".tar.gz")) {
extractCommand = `gunzip -c "${archivePath}" > "${archivePath.replace(/\.gz$/, "")}"`;
} else if (fileExt.endsWith(".bz2") && !fileExt.endsWith(".tar.bz2")) {
extractCommand = `bunzip2 -k "${archivePath}"`;
} else if (fileExt.endsWith(".xz") && !fileExt.endsWith(".tar.xz")) {
extractCommand = `unxz -k "${archivePath}"`;
} else if (fileExt.endsWith(".7z")) {
extractCommand = `7z x "${archivePath}" -o"${targetPath}"`;
} else if (fileExt.endsWith(".rar")) {
extractCommand = `unrar x "${archivePath}" "${targetPath}/"`;
} else {
return res.status(400).json({ error: "Unsupported archive format" });
}
fileLogger.info("Extracting archive", {
operation: "extract_archive",
sessionId,
archivePath,
extractPath: targetPath,
command: extractCommand,
});
session.client.exec(extractCommand, (err, stream) => {
if (err) {
fileLogger.error("SSH exec error during extract:", err, {
operation: "extract_archive",
sessionId,
archivePath,
});
return res
.status(500)
.json({ error: "Failed to execute extract command" });
}
let errorOutput = "";
stream.on("data", (data: Buffer) => {
fileLogger.debug("Extract stdout", {
operation: "extract_archive",
sessionId,
output: data.toString(),
});
});
stream.stderr.on("data", (data: Buffer) => {
errorOutput += data.toString();
fileLogger.debug("Extract stderr", {
operation: "extract_archive",
sessionId,
error: data.toString(),
});
});
stream.on("close", (code: number) => {
if (code !== 0) {
fileLogger.error("Extract command failed", {
operation: "extract_archive",
sessionId,
archivePath,
exitCode: code,
error: errorOutput,
});
// Check if command not found
let friendlyError = errorOutput || "Failed to extract archive";
if (
errorOutput.includes("command not found") ||
errorOutput.includes("not found")
) {
// Detect which command is missing based on file extension
let missingCmd = "";
let installHint = "";
if (fileExt.endsWith(".zip")) {
missingCmd = "unzip";
installHint =
"apt install unzip / yum install unzip / brew install unzip";
} else if (
fileExt.endsWith(".tar.gz") ||
fileExt.endsWith(".tgz") ||
fileExt.endsWith(".tar.bz2") ||
fileExt.endsWith(".tbz2") ||
fileExt.endsWith(".tar.xz") ||
fileExt.endsWith(".tar")
) {
missingCmd = "tar";
installHint = "Usually pre-installed on Linux/Unix systems";
} else if (fileExt.endsWith(".gz")) {
missingCmd = "gunzip";
installHint =
"apt install gzip / yum install gzip / Usually pre-installed";
} else if (fileExt.endsWith(".bz2")) {
missingCmd = "bunzip2";
installHint =
"apt install bzip2 / yum install bzip2 / brew install bzip2";
} else if (fileExt.endsWith(".xz")) {
missingCmd = "unxz";
installHint =
"apt install xz-utils / yum install xz / brew install xz";
} else if (fileExt.endsWith(".7z")) {
missingCmd = "7z";
installHint =
"apt install p7zip-full / yum install p7zip / brew install p7zip";
} else if (fileExt.endsWith(".rar")) {
missingCmd = "unrar";
installHint =
"apt install unrar / yum install unrar / brew install unrar";
}
if (missingCmd) {
friendlyError = `Command '${missingCmd}' not found on remote server. Please install it first: ${installHint}`;
}
}
return res.status(500).json({ error: friendlyError });
}
fileLogger.success("Archive extracted successfully", {
operation: "extract_archive",
sessionId,
archivePath,
extractPath: targetPath,
});
res.json({
success: true,
message: "Archive extracted successfully",
extractPath: targetPath,
});
});
stream.on("error", (streamErr) => {
fileLogger.error("SSH extractArchive stream error:", streamErr, {
operation: "extract_archive",
sessionId,
archivePath,
});
if (!res.headersSent) {
res
.status(500)
.json({ error: "Stream error while extracting archive" });
}
});
});
});
// Route: Compress files/folders (requires JWT)
// POST /ssh/file_manager/ssh/compressFiles
app.post("/ssh/file_manager/ssh/compressFiles", async (req, res) => {
const { sessionId, paths, archiveName, format } = req.body;
if (
!sessionId ||
!paths ||
!Array.isArray(paths) ||
paths.length === 0 ||
!archiveName
) {
return res.status(400).json({ error: "Missing required parameters" });
}
const session = sshSessions[sessionId];
if (!session || !session.isConnected) {
return res.status(400).json({ error: "SSH session not connected" });
}
session.lastActive = Date.now();
scheduleSessionCleanup(sessionId);
// Determine compression format
const compressionFormat = format || "zip"; // Default to zip
let compressCommand = "";
// Get the directory where the first file is located
const firstPath = paths[0];
const workingDir = firstPath.substring(0, firstPath.lastIndexOf("/")) || "/";
// Extract just the file/folder names for the command
const fileNames = paths
.map((p) => {
const name = p.split("/").pop();
return `"${name}"`;
})
.join(" ");
// Construct archive path
let archivePath = "";
if (archiveName.includes("/")) {
archivePath = archiveName;
} else {
archivePath = workingDir.endsWith("/")
? `${workingDir}${archiveName}`
: `${workingDir}/${archiveName}`;
}
if (compressionFormat === "zip") {
// Use zip command - need to cd to directory first
compressCommand = `cd "${workingDir}" && zip -r "${archivePath}" ${fileNames}`;
} else if (compressionFormat === "tar.gz" || compressionFormat === "tgz") {
compressCommand = `cd "${workingDir}" && tar -czf "${archivePath}" ${fileNames}`;
} else if (compressionFormat === "tar.bz2" || compressionFormat === "tbz2") {
compressCommand = `cd "${workingDir}" && tar -cjf "${archivePath}" ${fileNames}`;
} else if (compressionFormat === "tar.xz") {
compressCommand = `cd "${workingDir}" && tar -cJf "${archivePath}" ${fileNames}`;
} else if (compressionFormat === "tar") {
compressCommand = `cd "${workingDir}" && tar -cf "${archivePath}" ${fileNames}`;
} else if (compressionFormat === "7z") {
compressCommand = `cd "${workingDir}" && 7z a "${archivePath}" ${fileNames}`;
} else {
return res.status(400).json({ error: "Unsupported compression format" });
}
fileLogger.info("Compressing files", {
operation: "compress_files",
sessionId,
paths,
archivePath,
format: compressionFormat,
command: compressCommand,
});
session.client.exec(compressCommand, (err, stream) => {
if (err) {
fileLogger.error("SSH exec error during compress:", err, {
operation: "compress_files",
sessionId,
paths,
});
return res
.status(500)
.json({ error: "Failed to execute compress command" });
}
let errorOutput = "";
stream.on("data", (data: Buffer) => {
fileLogger.debug("Compress stdout", {
operation: "compress_files",
sessionId,
output: data.toString(),
});
});
stream.stderr.on("data", (data: Buffer) => {
errorOutput += data.toString();
fileLogger.debug("Compress stderr", {
operation: "compress_files",
sessionId,
error: data.toString(),
});
});
stream.on("close", (code: number) => {
if (code !== 0) {
fileLogger.error("Compress command failed", {
operation: "compress_files",
sessionId,
paths,
archivePath,
exitCode: code,
error: errorOutput,
});
// Check if command not found
let friendlyError = errorOutput || "Failed to compress files";
if (
errorOutput.includes("command not found") ||
errorOutput.includes("not found")
) {
const commandMap: Record<string, { cmd: string; install: string }> = {
zip: {
cmd: "zip",
install: "apt install zip / yum install zip / brew install zip",
},
"tar.gz": {
cmd: "tar",
install: "Usually pre-installed on Linux/Unix systems",
},
"tar.bz2": {
cmd: "tar",
install: "Usually pre-installed on Linux/Unix systems",
},
"tar.xz": {
cmd: "tar",
install: "Usually pre-installed on Linux/Unix systems",
},
tar: {
cmd: "tar",
install: "Usually pre-installed on Linux/Unix systems",
},
"7z": {
cmd: "7z",
install:
"apt install p7zip-full / yum install p7zip / brew install p7zip",
},
};
const info = commandMap[compressionFormat];
if (info) {
friendlyError = `Command '${info.cmd}' not found on remote server. Please install it first: ${info.install}`;
}
}
return res.status(500).json({ error: friendlyError });
}
fileLogger.success("Files compressed successfully", {
operation: "compress_files",
sessionId,
paths,
archivePath,
format: compressionFormat,
});
res.json({
success: true,
message: "Files compressed successfully",
archivePath: archivePath,
});
});
stream.on("error", (streamErr) => {
fileLogger.error("SSH compressFiles stream error:", streamErr, {
operation: "compress_files",
sessionId,
paths,
});
if (!res.headersSent) {
res.status(500).json({ error: "Stream error while compressing files" });
}
});
});
});
process.on("SIGINT", () => {
Object.keys(sshSessions).forEach(cleanupSession);
process.exit(0);

View File

@@ -111,6 +111,7 @@ class SystemCrypto {
} else {
}
} catch (fileError) {
// OK: .env file not found or unreadable, will generate new database key
databaseLogger.debug(
".env file not accessible, will generate new database key",
{