Finalized ssh config editor

This commit is contained in:
LukeGus
2025-07-28 14:23:28 -05:00
parent 962f0064fe
commit bc4c2dc7e6
17 changed files with 2617 additions and 1752 deletions

View File

@@ -1,7 +1,5 @@
import express from 'express';
import cors from 'cors';
import fs from 'fs';
import path from 'path';
import { Client as SSHClient } from 'ssh2';
import chalk from "chalk";
@@ -40,83 +38,6 @@ const logger = {
}
};
// --- Local File Operations ---
function normalizeFilePath(inputPath: string): string {
if (!inputPath || typeof inputPath !== 'string') throw new Error('Invalid path');
let normalizedPath = inputPath.replace(/\\/g, '/');
const windowsAbsPath = /^[a-zA-Z]:\//;
if (windowsAbsPath.test(normalizedPath)) return path.resolve(normalizedPath);
if (normalizedPath.startsWith('/')) return path.resolve(normalizedPath);
return path.resolve(process.cwd(), normalizedPath);
}
function isDirectory(p: string): boolean {
try { return fs.statSync(p).isDirectory(); } catch { return false; }
}
app.get('/files', (req, res) => {
try {
const folderParam = req.query.folder as string || '';
const folderPath = normalizeFilePath(folderParam);
if (!fs.existsSync(folderPath) || !isDirectory(folderPath)) {
logger.error('Directory not found:', folderPath);
return res.status(404).json({ error: 'Directory not found' });
}
fs.readdir(folderPath, { withFileTypes: true }, (err, files) => {
if (err) {
logger.error('Error reading directory:', err);
return res.status(500).json({ error: err.message });
}
const result = files.map(f => ({ name: f.name, type: f.isDirectory() ? 'directory' : 'file' }));
res.json(result);
});
} catch (err: any) {
logger.error('Error in /files endpoint:', err);
res.status(400).json({ error: err.message });
}
});
app.get('/file', (req, res) => {
try {
const folderParam = req.query.folder as string || '';
const fileName = req.query.name as string;
if (!fileName) return res.status(400).json({ error: 'Missing "name" parameter' });
const folderPath = normalizeFilePath(folderParam);
const filePath = path.join(folderPath, fileName);
if (!fs.existsSync(filePath)) {
logger.error(`File not found: ${filePath}`);
return res.status(404).json({ error: 'File not found' });
}
if (isDirectory(filePath)) {
logger.error(`Path is a directory: ${filePath}`);
return res.status(400).json({ error: 'Path is a directory' });
}
const content = fs.readFileSync(filePath, 'utf8');
res.setHeader('Content-Type', 'text/plain');
res.send(content);
} catch (err: any) {
logger.error('Error in /file GET endpoint:', err);
res.status(500).json({ error: err.message });
}
});
app.post('/file', (req, res) => {
try {
const folderParam = req.query.folder as string || '';
const fileName = req.query.name as string;
const content = req.body.content;
if (!fileName) return res.status(400).json({ error: 'Missing "name" parameter' });
if (content === undefined) return res.status(400).json({ error: 'Missing "content" in request body' });
const folderPath = normalizeFilePath(folderParam);
const filePath = path.join(folderPath, fileName);
if (!fs.existsSync(folderPath)) fs.mkdirSync(folderPath, { recursive: true });
fs.writeFileSync(filePath, content, 'utf8');
res.json({ message: 'File written successfully' });
} catch (err: any) {
logger.error('Error in /file POST endpoint:', err);
res.status(500).json({ error: err.message });
}
});
// --- SSH Operations (per-session, in-memory, with cleanup) ---
interface SSHSession {
client: SSHClient;
@@ -136,6 +57,7 @@ function cleanupSession(sessionId: string) {
logger.info(`Cleaned up SSH session: ${sessionId}`);
}
}
function scheduleSessionCleanup(sessionId: string) {
const session = sshSessions[sessionId];
if (session) {
@@ -144,68 +66,130 @@ function scheduleSessionCleanup(sessionId: string) {
}
}
app.post('/ssh/connect', (req, res) => {
app.post('/ssh/config_editor/ssh/connect', (req, res) => {
const { sessionId, ip, port, username, password, sshKey, keyPassword } = req.body;
if (!sessionId || !ip || !username || !port) {
logger.warn('Missing SSH connection parameters');
return res.status(400).json({ error: 'Missing SSH connection parameters' });
}
logger.info(`Attempting SSH connection: ${ip}:${port} as ${username} (session: ${sessionId})`);
logger.info(`Auth method: ${sshKey ? 'SSH Key' : password ? 'Password' : 'None'}`);
logger.info(`Request body keys: ${Object.keys(req.body).join(', ')}`);
logger.info(`Password present: ${!!password}, Key present: ${!!sshKey}`);
if (sshSessions[sessionId]?.isConnected) cleanupSession(sessionId);
const client = new SSHClient();
const config: any = {
host: ip, port: port || 22, username,
readyTimeout: 20000, keepaliveInterval: 10000, keepaliveCountMax: 3,
host: ip,
port: port || 22,
username,
readyTimeout: 20000,
keepaliveInterval: 10000,
keepaliveCountMax: 3,
};
if (sshKey) { config.privateKey = sshKey; if (keyPassword) config.passphrase = keyPassword; }
else if (password) config.password = password;
else { logger.warn('No password or key provided'); return res.status(400).json({ error: 'Either password or SSH key must be provided' }); }
if (sshKey && sshKey.trim()) {
config.privateKey = sshKey;
if (keyPassword) config.passphrase = keyPassword;
logger.info('Using SSH key authentication');
}
else if (password && password.trim()) {
config.password = password;
logger.info('Using password authentication');
}
else {
logger.warn('No password or key provided');
return res.status(400).json({ error: 'Either password or SSH key must be provided' });
}
// Create a response promise to handle async connection
let responseSent = false;
client.on('ready', () => {
if (responseSent) return;
responseSent = true;
sshSessions[sessionId] = { client, isConnected: true, lastActive: Date.now() };
scheduleSessionCleanup(sessionId);
logger.info(`SSH connected: ${ip}:${port} as ${username} (session: ${sessionId})`);
res.json({ status: 'success', message: 'SSH connection established' });
});
client.on('error', (err) => {
logger.error('SSH connection error:', err.message);
if (responseSent) return;
responseSent = true;
logger.error(`SSH connection error for session ${sessionId}:`, err.message);
logger.error(`Connection details: ${ip}:${port} as ${username}`);
res.status(500).json({ status: 'error', message: err.message });
});
client.on('close', () => {
logger.info(`SSH connection closed for session ${sessionId}`);
if (sshSessions[sessionId]) sshSessions[sessionId].isConnected = false;
cleanupSession(sessionId);
});
client.connect(config);
});
app.post('/ssh/disconnect', (req, res) => {
app.post('/ssh/config_editor/ssh/disconnect', (req, res) => {
const { sessionId } = req.body;
cleanupSession(sessionId);
res.json({ status: 'success', message: 'SSH connection disconnected' });
});
app.get('/ssh/status', (req, res) => {
app.get('/ssh/config_editor/ssh/status', (req, res) => {
const sessionId = req.query.sessionId as string;
const isConnected = !!sshSessions[sessionId]?.isConnected;
res.json({ status: 'success', connected: isConnected });
});
app.get('/ssh/listFiles', (req, res) => {
app.get('/ssh/config_editor/ssh/listFiles', (req, res) => {
const sessionId = req.query.sessionId as string;
const sshConn = sshSessions[sessionId];
const { path: sshPath = '/' } = req.query;
const sshPath = decodeURIComponent((req.query.path as string) || '/');
if (!sessionId) {
logger.warn('Session ID is required for listFiles');
return res.status(400).json({ error: 'Session ID is required' });
}
if (!sshConn?.isConnected) {
logger.warn(`SSH connection not established for session: ${sessionId}`);
return res.status(400).json({ error: 'SSH connection not established' });
}
sshConn.lastActive = Date.now();
scheduleSessionCleanup(sessionId);
sshConn.client.exec(`ls -la "${sshPath}"`, (err, stream) => {
if (err) { logger.error('SSH listFiles error:', err); return res.status(500).json({ error: err.message }); }
// Escape the path properly for shell command
const escapedPath = sshPath.replace(/'/g, "'\"'\"'");
sshConn.client.exec(`ls -la '${escapedPath}'`, (err, stream) => {
if (err) {
logger.error('SSH listFiles error:', err);
return res.status(500).json({ error: err.message });
}
let data = '';
stream.on('data', (chunk: Buffer) => { data += chunk.toString(); });
stream.stderr.on('data', (_chunk: Buffer) => { /* ignore for now */ });
stream.on('close', () => {
let errorData = '';
stream.on('data', (chunk: Buffer) => {
data += chunk.toString();
});
stream.stderr.on('data', (chunk: Buffer) => {
errorData += chunk.toString();
});
stream.on('close', (code) => {
if (code !== 0) {
logger.error(`SSH listFiles command failed with code ${code}: ${errorData}`);
return res.status(500).json({ error: `Command failed: ${errorData}` });
}
const lines = data.split('\n').filter(line => line.trim());
const files = [];
for (let i = 1; i < lines.length; i++) {
const line = lines[i];
const parts = line.split(/\s+/);
@@ -214,63 +198,249 @@ app.get('/ssh/listFiles', (req, res) => {
const name = parts.slice(8).join(' ');
const isDirectory = permissions.startsWith('d');
const isLink = permissions.startsWith('l');
files.push({ name, type: isDirectory ? 'directory' : (isLink ? 'link' : 'file') });
// Skip . and .. directories
if (name === '.' || name === '..') continue;
files.push({
name,
type: isDirectory ? 'directory' : (isLink ? 'link' : 'file')
});
}
}
res.json(files);
});
});
});
app.get('/ssh/readFile', (req, res) => {
app.get('/ssh/config_editor/ssh/readFile', (req, res) => {
const sessionId = req.query.sessionId as string;
const sshConn = sshSessions[sessionId];
const { path: filePath } = req.query;
const filePath = decodeURIComponent(req.query.path as string);
if (!sessionId) {
logger.warn('Session ID is required for readFile');
return res.status(400).json({ error: 'Session ID is required' });
}
if (!sshConn?.isConnected) {
logger.warn(`SSH connection not established for session: ${sessionId}`);
return res.status(400).json({ error: 'SSH connection not established' });
}
if (!filePath) {
logger.warn('File path is required for readFile');
return res.status(400).json({ error: 'File path is required' });
}
sshConn.lastActive = Date.now();
scheduleSessionCleanup(sessionId);
sshConn.client.exec(`cat "${filePath}"`, (err, stream) => {
if (err) { logger.error('SSH readFile error:', err); return res.status(500).json({ error: err.message }); }
// Escape the file path properly
const escapedPath = filePath.replace(/'/g, "'\"'\"'");
sshConn.client.exec(`cat '${escapedPath}'`, (err, stream) => {
if (err) {
logger.error('SSH readFile error:', err);
return res.status(500).json({ error: err.message });
}
let data = '';
stream.on('data', (chunk: Buffer) => { data += chunk.toString(); });
stream.stderr.on('data', (_chunk: Buffer) => { /* ignore for now */ });
stream.on('close', () => {
let errorData = '';
stream.on('data', (chunk: Buffer) => {
data += chunk.toString();
});
stream.stderr.on('data', (chunk: Buffer) => {
errorData += chunk.toString();
});
stream.on('close', (code) => {
if (code !== 0) {
logger.error(`SSH readFile command failed with code ${code}: ${errorData}`);
return res.status(500).json({ error: `Command failed: ${errorData}` });
}
res.json({ content: data, path: filePath });
});
});
});
app.post('/ssh/writeFile', (req, res) => {
app.post('/ssh/config_editor/ssh/writeFile', (req, res) => {
const { sessionId, path: filePath, content } = req.body;
const sshConn = sshSessions[sessionId];
if (!sessionId) {
logger.warn('Session ID is required for writeFile');
return res.status(400).json({ error: 'Session ID is required' });
}
if (!sshConn?.isConnected) {
logger.warn(`SSH connection not established for session: ${sessionId}`);
return res.status(400).json({ error: 'SSH connection not established' });
}
logger.info(`SSH connection status for session ${sessionId}: connected=${sshConn.isConnected}, lastActive=${new Date(sshConn.lastActive).toISOString()}`);
if (!filePath) {
logger.warn('File path is required for writeFile');
return res.status(400).json({ error: 'File path is required' });
}
if (content === undefined) {
logger.warn('File content is required for writeFile');
return res.status(400).json({ error: 'File content is required' });
}
sshConn.lastActive = Date.now();
scheduleSessionCleanup(sessionId);
// Write to a temp file, then move
// Write to a temp file, then move - properly escape paths and content
const tempFile = `/tmp/temp_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
const safeContent = content.replace(/'/g, "'\"'\"'");
sshConn.client.exec(`echo '${safeContent}' > "${tempFile}" && mv "${tempFile}" "${filePath}"`, (err, stream) => {
if (err) { logger.error('SSH writeFile error:', err); return res.status(500).json({ error: err.message }); }
stream.on('close', () => {
res.json({ message: 'File written successfully', path: filePath });
const escapedTempFile = tempFile.replace(/'/g, "'\"'\"'");
const escapedFilePath = filePath.replace(/'/g, "'\"'\"'");
// Use base64 encoding to safely transfer content
const base64Content = Buffer.from(content, 'utf8').toString('base64');
logger.info(`Starting writeFile operation: session=${sessionId}, path=${filePath}, contentLength=${content.length}, base64Length=${base64Content.length}`);
// Add timeout to prevent hanging
const commandTimeout = setTimeout(() => {
logger.error(`SSH writeFile command timed out for session: ${sessionId}`);
if (!res.headersSent) {
res.status(500).json({ error: 'SSH command timed out' });
}
}, 15000); // 15 second timeout
// First check file permissions and ownership
const checkCommand = `ls -la '${escapedFilePath}' 2>/dev/null || echo "File does not exist"`;
logger.info(`Checking file details: ${filePath}`);
sshConn.client.exec(checkCommand, (checkErr, checkStream) => {
if (checkErr) {
logger.error('File check failed:', checkErr);
return res.status(500).json({ error: `File check failed: ${checkErr.message}` });
}
let checkResult = '';
checkStream.on('data', (chunk: Buffer) => {
checkResult += chunk.toString();
});
checkStream.on('close', (checkCode) => {
logger.info(`File check result: ${checkResult.trim()}`);
// Use a simpler approach: write base64 to temp file, decode and write to target, then clean up
// Add explicit exit to ensure the command completes
const writeCommand = `echo '${base64Content}' > '${escapedTempFile}' && base64 -d '${escapedTempFile}' > '${escapedFilePath}' && rm -f '${escapedTempFile}' && echo "SUCCESS" && exit 0`;
logger.info(`Executing write command for: ${filePath}`);
sshConn.client.exec(writeCommand, (err, stream) => {
if (err) {
clearTimeout(commandTimeout);
logger.error('SSH writeFile error:', err);
if (!res.headersSent) {
return res.status(500).json({ error: err.message });
}
return;
}
let outputData = '';
let errorData = '';
stream.on('data', (chunk: Buffer) => {
outputData += chunk.toString();
logger.debug(`SSH writeFile stdout: ${chunk.toString()}`);
});
stream.stderr.on('data', (chunk: Buffer) => {
errorData += chunk.toString();
logger.debug(`SSH writeFile stderr: ${chunk.toString()}`);
// Check for permission denied and fail fast
if (chunk.toString().includes('Permission denied')) {
clearTimeout(commandTimeout);
logger.error(`Permission denied writing to file: ${filePath}`);
if (!res.headersSent) {
return res.status(403).json({
error: `Permission denied: Cannot write to ${filePath}. Check file ownership and permissions. Use 'ls -la ${filePath}' to verify.`
});
}
return;
}
});
stream.on('close', (code) => {
logger.info(`SSH writeFile command completed with code: ${code}, output: "${outputData.trim()}", error: "${errorData.trim()}"`);
clearTimeout(commandTimeout);
// Check if we got the success message
if (outputData.includes('SUCCESS')) {
// Verify the file was actually written by checking its size
const verifyCommand = `ls -la '${escapedFilePath}' 2>/dev/null | awk '{print $5}'`;
logger.info(`Verifying file was written: ${filePath}`);
sshConn.client.exec(verifyCommand, (verifyErr, verifyStream) => {
if (verifyErr) {
logger.warn('File verification failed, but assuming success:');
if (!res.headersSent) {
res.json({ message: 'File written successfully', path: filePath });
}
return;
}
let verifyResult = '';
verifyStream.on('data', (chunk: Buffer) => {
verifyResult += chunk.toString();
});
verifyStream.on('close', (verifyCode) => {
const fileSize = Number(verifyResult.trim());
logger.info(`File verification result: size=${fileSize} bytes`);
if (fileSize > 0) {
logger.info(`File written successfully: ${filePath} (${fileSize} bytes)`);
if (!res.headersSent) {
res.json({ message: 'File written successfully', path: filePath });
}
} else {
logger.error(`File appears to be empty after write: ${filePath}`);
if (!res.headersSent) {
res.status(500).json({ error: 'File write operation may have failed - file appears empty' });
}
}
});
});
return;
}
if (code !== 0) {
logger.error(`SSH writeFile command failed with code ${code}: ${errorData}`);
if (!res.headersSent) {
return res.status(500).json({ error: `Command failed: ${errorData}` });
}
return;
}
// If code is 0 but no SUCCESS message, assume it worked anyway
// This handles cases where the echo "SUCCESS" didn't work but the file write did
logger.info(`File written successfully (code 0, no SUCCESS message): ${filePath}`);
if (!res.headersSent) {
res.json({ message: 'File written successfully', path: filePath });
}
});
stream.on('error', (streamErr) => {
clearTimeout(commandTimeout);
logger.error('SSH writeFile stream error:', streamErr);
if (!res.headersSent) {
res.status(500).json({ error: `Stream error: ${streamErr.message}` });
}
});
});
});
});
});

View File

@@ -78,6 +78,39 @@ CREATE TABLE IF NOT EXISTS ssh_data (
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(user_id) REFERENCES users(id)
);
CREATE TABLE IF NOT EXISTS config_editor_recent (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
host_id INTEGER NOT NULL,
name TEXT NOT NULL,
path TEXT NOT NULL,
last_opened TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(user_id) REFERENCES users(id),
FOREIGN KEY(host_id) REFERENCES ssh_data(id)
);
CREATE TABLE IF NOT EXISTS config_editor_pinned (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
host_id INTEGER NOT NULL,
name TEXT NOT NULL,
path TEXT NOT NULL,
pinned_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(user_id) REFERENCES users(id),
FOREIGN KEY(host_id) REFERENCES ssh_data(id)
);
CREATE TABLE IF NOT EXISTS config_editor_shortcuts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
host_id INTEGER NOT NULL,
name TEXT NOT NULL,
path TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(user_id) REFERENCES users(id),
FOREIGN KEY(host_id) REFERENCES ssh_data(id)
);
`);
// Function to safely add a column if it doesn't exist
@@ -120,6 +153,11 @@ const migrateSchema = () => {
addColumnIfNotExists('ssh_data', 'created_at', 'TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP');
addColumnIfNotExists('ssh_data', 'updated_at', 'TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP');
// Add missing columns to config_editor tables
addColumnIfNotExists('config_editor_recent', 'host_id', 'INTEGER NOT NULL');
addColumnIfNotExists('config_editor_pinned', 'host_id', 'INTEGER NOT NULL');
addColumnIfNotExists('config_editor_shortcuts', 'host_id', 'INTEGER NOT NULL');
logger.success('Schema migration completed');
};

View File

@@ -35,4 +35,31 @@ export const sshData = sqliteTable('ssh_data', {
defaultPath: text('default_path'), // Default path for SSH connection
createdAt: text('created_at').notNull().default(sql`CURRENT_TIMESTAMP`),
updatedAt: text('updated_at').notNull().default(sql`CURRENT_TIMESTAMP`),
});
export const configEditorRecent = sqliteTable('config_editor_recent', {
id: integer('id').primaryKey({ autoIncrement: true }),
userId: text('user_id').notNull().references(() => users.id),
hostId: integer('host_id').notNull().references(() => sshData.id), // SSH host ID
name: text('name').notNull(), // File name
path: text('path').notNull(), // File path
lastOpened: text('last_opened').notNull().default(sql`CURRENT_TIMESTAMP`),
});
export const configEditorPinned = sqliteTable('config_editor_pinned', {
id: integer('id').primaryKey({ autoIncrement: true }),
userId: text('user_id').notNull().references(() => users.id),
hostId: integer('host_id').notNull().references(() => sshData.id), // SSH host ID
name: text('name').notNull(), // File name
path: text('path').notNull(), // File path
pinnedAt: text('pinned_at').notNull().default(sql`CURRENT_TIMESTAMP`),
});
export const configEditorShortcuts = sqliteTable('config_editor_shortcuts', {
id: integer('id').primaryKey({ autoIncrement: true }),
userId: text('user_id').notNull().references(() => users.id),
hostId: integer('host_id').notNull().references(() => sshData.id), // SSH host ID
name: text('name').notNull(), // Folder name
path: text('path').notNull(), // Folder path
createdAt: text('created_at').notNull().default(sql`CURRENT_TIMESTAMP`),
});

View File

@@ -1,7 +1,7 @@
import express from 'express';
import { db } from '../db/index.js';
import { sshData } from '../db/schema.js';
import { eq, and } from 'drizzle-orm';
import { sshData, configEditorRecent, configEditorPinned, configEditorShortcuts } from '../db/schema.js';
import { eq, and, desc } from 'drizzle-orm';
import chalk from 'chalk';
import jwt from 'jsonwebtoken';
import multer from 'multer';
@@ -384,4 +384,312 @@ router.delete('/db/host/:id', authenticateJWT, async (req: Request, res: Respons
}
});
// Config Editor Database Routes
// Route: Get recent files (requires JWT)
// GET /ssh/config_editor/recent
router.get('/config_editor/recent', authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as any).userId;
const hostId = req.query.hostId ? parseInt(req.query.hostId as string) : null;
if (!isNonEmptyString(userId)) {
logger.warn('Invalid userId for recent files fetch');
return res.status(400).json({ error: 'Invalid userId' });
}
if (!hostId) {
logger.warn('Host ID is required for recent files fetch');
return res.status(400).json({ error: 'Host ID is required' });
}
try {
const recentFiles = await db
.select()
.from(configEditorRecent)
.where(and(
eq(configEditorRecent.userId, userId),
eq(configEditorRecent.hostId, hostId)
))
.orderBy(desc(configEditorRecent.lastOpened));
res.json(recentFiles);
} catch (err) {
logger.error('Failed to fetch recent files', err);
res.status(500).json({ error: 'Failed to fetch recent files' });
}
});
// Route: Add file to recent (requires JWT)
// POST /ssh/config_editor/recent
router.post('/config_editor/recent', authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as any).userId;
const { name, path, hostId } = req.body;
if (!isNonEmptyString(userId) || !name || !path || !hostId) {
logger.warn('Invalid request for adding recent file');
return res.status(400).json({ error: 'Invalid request - userId, name, path, and hostId are required' });
}
try {
// Check if file already exists in recent for this host
const conditions = [
eq(configEditorRecent.userId, userId),
eq(configEditorRecent.path, path),
eq(configEditorRecent.hostId, hostId)
];
const existing = await db
.select()
.from(configEditorRecent)
.where(and(...conditions));
if (existing.length > 0) {
// Update lastOpened timestamp
await db
.update(configEditorRecent)
.set({ lastOpened: new Date().toISOString() })
.where(and(...conditions));
} else {
// Add new recent file
await db.insert(configEditorRecent).values({
userId,
hostId,
name,
path,
lastOpened: new Date().toISOString()
});
}
res.json({ message: 'File added to recent' });
} catch (err) {
logger.error('Failed to add recent file', err);
res.status(500).json({ error: 'Failed to add recent file' });
}
});
// Route: Remove file from recent (requires JWT)
// DELETE /ssh/config_editor/recent
router.delete('/config_editor/recent', authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as any).userId;
const { name, path, hostId } = req.body;
if (!isNonEmptyString(userId) || !name || !path || !hostId) {
logger.warn('Invalid request for removing recent file');
return res.status(400).json({ error: 'Invalid request - userId, name, path, and hostId are required' });
}
try {
logger.info(`Removing recent file: ${name} at ${path} for user ${userId} and host ${hostId}`);
const conditions = [
eq(configEditorRecent.userId, userId),
eq(configEditorRecent.path, path),
eq(configEditorRecent.hostId, hostId)
];
const result = await db
.delete(configEditorRecent)
.where(and(...conditions));
logger.info(`Recent file removed successfully`);
res.json({ message: 'File removed from recent' });
} catch (err) {
logger.error('Failed to remove recent file', err);
res.status(500).json({ error: 'Failed to remove recent file' });
}
});
// Route: Get pinned files (requires JWT)
// GET /ssh/config_editor/pinned
router.get('/config_editor/pinned', authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as any).userId;
const hostId = req.query.hostId ? parseInt(req.query.hostId as string) : null;
if (!isNonEmptyString(userId)) {
logger.warn('Invalid userId for pinned files fetch');
return res.status(400).json({ error: 'Invalid userId' });
}
if (!hostId) {
logger.warn('Host ID is required for pinned files fetch');
return res.status(400).json({ error: 'Host ID is required' });
}
try {
const pinnedFiles = await db
.select()
.from(configEditorPinned)
.where(and(
eq(configEditorPinned.userId, userId),
eq(configEditorPinned.hostId, hostId)
))
.orderBy(configEditorPinned.pinnedAt);
res.json(pinnedFiles);
} catch (err) {
logger.error('Failed to fetch pinned files', err);
res.status(500).json({ error: 'Failed to fetch pinned files' });
}
});
// Route: Add file to pinned (requires JWT)
// POST /ssh/config_editor/pinned
router.post('/config_editor/pinned', authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as any).userId;
const { name, path, hostId } = req.body;
if (!isNonEmptyString(userId) || !name || !path || !hostId) {
logger.warn('Invalid request for adding pinned file');
return res.status(400).json({ error: 'Invalid request - userId, name, path, and hostId are required' });
}
try {
// Check if file already exists in pinned for this host
const conditions = [
eq(configEditorPinned.userId, userId),
eq(configEditorPinned.path, path),
eq(configEditorPinned.hostId, hostId)
];
const existing = await db
.select()
.from(configEditorPinned)
.where(and(...conditions));
if (existing.length === 0) {
// Add new pinned file
await db.insert(configEditorPinned).values({
userId,
hostId,
name,
path,
pinnedAt: new Date().toISOString()
});
}
res.json({ message: 'File pinned successfully' });
} catch (err) {
logger.error('Failed to pin file', err);
res.status(500).json({ error: 'Failed to pin file' });
}
});
// Route: Remove file from pinned (requires JWT)
// DELETE /ssh/config_editor/pinned
router.delete('/config_editor/pinned', authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as any).userId;
const { name, path, hostId } = req.body;
if (!isNonEmptyString(userId) || !name || !path || !hostId) {
logger.warn('Invalid request for removing pinned file');
return res.status(400).json({ error: 'Invalid request - userId, name, path, and hostId are required' });
}
try {
logger.info(`Removing pinned file: ${name} at ${path} for user ${userId} and host ${hostId}`);
const conditions = [
eq(configEditorPinned.userId, userId),
eq(configEditorPinned.path, path),
eq(configEditorPinned.hostId, hostId)
];
const result = await db
.delete(configEditorPinned)
.where(and(...conditions));
logger.info(`Pinned file removed successfully`);
res.json({ message: 'File unpinned successfully' });
} catch (err) {
logger.error('Failed to unpin file', err);
res.status(500).json({ error: 'Failed to unpin file' });
}
});
// Route: Get folder shortcuts (requires JWT)
// GET /ssh/config_editor/shortcuts
router.get('/config_editor/shortcuts', authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as any).userId;
const hostId = req.query.hostId ? parseInt(req.query.hostId as string) : null;
if (!isNonEmptyString(userId)) {
logger.warn('Invalid userId for shortcuts fetch');
return res.status(400).json({ error: 'Invalid userId' });
}
if (!hostId) {
logger.warn('Host ID is required for shortcuts fetch');
return res.status(400).json({ error: 'Host ID is required' });
}
try {
const shortcuts = await db
.select()
.from(configEditorShortcuts)
.where(and(
eq(configEditorShortcuts.userId, userId),
eq(configEditorShortcuts.hostId, hostId)
))
.orderBy(configEditorShortcuts.createdAt);
res.json(shortcuts);
} catch (err) {
logger.error('Failed to fetch shortcuts', err);
res.status(500).json({ error: 'Failed to fetch shortcuts' });
}
});
// Route: Add folder shortcut (requires JWT)
// POST /ssh/config_editor/shortcuts
router.post('/config_editor/shortcuts', authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as any).userId;
const { name, path, hostId } = req.body;
if (!isNonEmptyString(userId) || !name || !path || !hostId) {
logger.warn('Invalid request for adding shortcut');
return res.status(400).json({ error: 'Invalid request - userId, name, path, and hostId are required' });
}
try {
// Check if shortcut already exists for this host
const conditions = [
eq(configEditorShortcuts.userId, userId),
eq(configEditorShortcuts.path, path),
eq(configEditorShortcuts.hostId, hostId)
];
const existing = await db
.select()
.from(configEditorShortcuts)
.where(and(...conditions));
if (existing.length === 0) {
// Add new shortcut
await db.insert(configEditorShortcuts).values({
userId,
hostId,
name,
path,
createdAt: new Date().toISOString()
});
}
res.json({ message: 'Shortcut added successfully' });
} catch (err) {
logger.error('Failed to add shortcut', err);
res.status(500).json({ error: 'Failed to add shortcut' });
}
});
// Route: Remove folder shortcut (requires JWT)
// DELETE /ssh/config_editor/shortcuts
router.delete('/config_editor/shortcuts', authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as any).userId;
const { name, path, hostId } = req.body;
if (!isNonEmptyString(userId) || !name || !path || !hostId) {
logger.warn('Invalid request for removing shortcut');
return res.status(400).json({ error: 'Invalid request - userId, name, path, and hostId are required' });
}
try {
logger.info(`Removing shortcut: ${name} at ${path} for user ${userId} and host ${hostId}`);
const conditions = [
eq(configEditorShortcuts.userId, userId),
eq(configEditorShortcuts.path, path),
eq(configEditorShortcuts.hostId, hostId)
];
const result = await db
.delete(configEditorShortcuts)
.where(and(...conditions));
logger.info(`Shortcut removed successfully`);
res.json({ message: 'Shortcut removed successfully' });
} catch (err) {
logger.error('Failed to remove shortcut', err);
res.status(500).json({ error: 'Failed to remove shortcut' });
}
});
export default router;