Finalized ssh config editor
This commit is contained in:
@@ -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}` });
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
};
|
||||
|
||||
|
||||
@@ -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`),
|
||||
});
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user