fix: Merge zac/luke
This commit is contained in:
@@ -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(
|
||||
(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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`),
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
|
||||
214
src/backend/database/routes/terminal.ts
Normal file
214
src/backend/database/routes/terminal.ts
Normal 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;
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user