Finalized ssh tunnels, updatetd database schemas, started on config editor.

This commit is contained in:
LukeGus
2025-07-23 00:36:22 -05:00
parent 547701378f
commit 608111c37b
26 changed files with 5043 additions and 200 deletions

View File

@@ -0,0 +1,290 @@
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";
const app = express();
const PORT = 8084;
app.use(cors({
origin: 'http://localhost:5173',
credentials: true,
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization']
}));
app.use(express.json());
const sshIconSymbol = '📁';
const getTimeStamp = (): string => chalk.gray(`[${new Date().toLocaleTimeString()}]`);
const formatMessage = (level: string, colorFn: chalk.Chalk, message: string): string => {
return `${getTimeStamp()} ${colorFn(`[${level.toUpperCase()}]`)} ${chalk.hex('#1e3a8a')(`[${sshIconSymbol}]`)} ${message}`;
};
const logger = {
info: (msg: string): void => {
console.log(formatMessage('info', chalk.cyan, msg));
},
warn: (msg: string): void => {
console.warn(formatMessage('warn', chalk.yellow, msg));
},
error: (msg: string, err?: unknown): void => {
console.error(formatMessage('error', chalk.redBright, msg));
if (err) console.error(err);
},
success: (msg: string): void => {
console.log(formatMessage('success', chalk.greenBright, msg));
},
debug: (msg: string): void => {
if (process.env.NODE_ENV !== 'production') {
console.debug(formatMessage('debug', chalk.magenta, msg));
}
}
};
// --- 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;
isConnected: boolean;
lastActive: number;
timeout?: NodeJS.Timeout;
}
const sshSessions: Record<string, SSHSession> = {};
const SESSION_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes
function cleanupSession(sessionId: string) {
const session = sshSessions[sessionId];
if (session) {
try { session.client.end(); } catch {}
clearTimeout(session.timeout);
delete sshSessions[sessionId];
logger.info(`Cleaned up SSH session: ${sessionId}`);
}
}
function scheduleSessionCleanup(sessionId: string) {
const session = sshSessions[sessionId];
if (session) {
if (session.timeout) clearTimeout(session.timeout);
session.timeout = setTimeout(() => cleanupSession(sessionId), SESSION_TIMEOUT_MS);
}
}
app.post('/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' });
}
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,
};
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' }); }
client.on('ready', () => {
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);
res.status(500).json({ status: 'error', message: err.message });
});
client.on('close', () => {
if (sshSessions[sessionId]) sshSessions[sessionId].isConnected = false;
cleanupSession(sessionId);
});
client.connect(config);
});
app.post('/ssh/disconnect', (req, res) => {
const { sessionId } = req.body;
cleanupSession(sessionId);
res.json({ status: 'success', message: 'SSH connection disconnected' });
});
app.get('/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) => {
const sessionId = req.query.sessionId as string;
const sshConn = sshSessions[sessionId];
const { path: sshPath = '/' } = req.query;
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 }); }
let data = '';
stream.on('data', (chunk: Buffer) => { data += chunk.toString(); });
stream.stderr.on('data', (_chunk: Buffer) => { /* ignore for now */ });
stream.on('close', () => {
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+/);
if (parts.length >= 9) {
const permissions = parts[0];
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') });
}
}
res.json(files);
});
});
});
app.get('/ssh/readFile', (req, res) => {
const sessionId = req.query.sessionId as string;
const sshConn = sshSessions[sessionId];
const { path: filePath } = req.query;
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 }); }
let data = '';
stream.on('data', (chunk: Buffer) => { data += chunk.toString(); });
stream.stderr.on('data', (_chunk: Buffer) => { /* ignore for now */ });
stream.on('close', () => {
res.json({ content: data, path: filePath });
});
});
});
app.post('/ssh/writeFile', (req, res) => {
const { sessionId, path: filePath, content } = req.body;
const sshConn = sshSessions[sessionId];
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 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
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 });
});
});
});
process.on('SIGINT', () => {
Object.keys(sshSessions).forEach(cleanupSession);
process.exit(0);
});
process.on('SIGTERM', () => {
Object.keys(sshSessions).forEach(cleanupSession);
process.exit(0);
});
app.listen(PORT, () => {});

View File

@@ -0,0 +1,401 @@
const express = require('express');
const http = require('http');
const Database = require('better-sqlite3');
const bcrypt = require('bcrypt');
const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
const cors = require("cors");
const jwt = require('jsonwebtoken');
require('dotenv').config();
const app = express();
const PORT = 8081;
app.use(cors({
origin: true,
credentials: true,
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization']
}));
app.use(express.json());
const getReadableTimestamp = () => {
return new Intl.DateTimeFormat('en-US', {
dateStyle: 'medium',
timeStyle: 'medium',
timeZone: 'UTC',
}).format(new Date());
};
const logger = {
info: (...args) => console.log(`💾 | 🔧 [${getReadableTimestamp()}] INFO:`, ...args),
error: (...args) => console.error(`💾 | ❌ [${getReadableTimestamp()}] ERROR:`, ...args),
warn: (...args) => console.warn(`💾 | ⚠️ [${getReadableTimestamp()}] WARN:`, ...args),
debug: (...args) => console.debug(`💾 | 🔍 [${getReadableTimestamp()}] DEBUG:`, ...args)
};
const SALT = process.env.SALT || 'default_salt';
const JWT_SECRET = SALT + '_jwt_secret';
const DB_PATH = path.join(__dirname, 'data', 'users.db');
const dataDir = path.join(__dirname, 'data');
if (!fs.existsSync(dataDir)) {
fs.mkdirSync(dataDir, { recursive: true });
}
const db = new Database(DB_PATH);
db.pragma('journal_mode = WAL');
db.prepare(`CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
password TEXT NOT NULL,
created_at TEXT NOT NULL,
is_admin BOOLEAN DEFAULT 0,
theme TEXT DEFAULT 'vscode'
)`).run();
db.prepare(`CREATE TABLE IF NOT EXISTS settings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
signup_enabled BOOLEAN DEFAULT 1
)`).run();
const settingsCount = db.prepare('SELECT COUNT(*) as count FROM settings').get().count;
if (settingsCount === 0) {
db.prepare('INSERT INTO settings (signup_enabled) VALUES (1)').run();
}
db.prepare(`CREATE TABLE IF NOT EXISTS user_recent_files (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
file_path TEXT NOT NULL,
file_name TEXT NOT NULL,
last_opened TEXT NOT NULL,
server_name TEXT,
server_ip TEXT,
server_port INTEGER,
server_user TEXT,
server_default_path TEXT,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)`).run();
db.prepare(`CREATE TABLE IF NOT EXISTS user_starred_files (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
file_path TEXT NOT NULL,
file_name TEXT NOT NULL,
last_opened TEXT NOT NULL,
server_name TEXT,
server_ip TEXT,
server_port INTEGER,
server_user TEXT,
server_default_path TEXT,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)`).run();
db.prepare(`CREATE TABLE IF NOT EXISTS user_folder_shortcuts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
folder_path TEXT NOT NULL,
folder_name TEXT NOT NULL,
server_name TEXT,
server_ip TEXT,
server_port INTEGER,
server_user TEXT,
server_default_path TEXT,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)`).run();
db.prepare(`CREATE TABLE IF NOT EXISTS user_open_tabs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
tab_id TEXT NOT NULL,
file_name TEXT NOT NULL,
file_path TEXT NOT NULL,
content TEXT,
saved_content TEXT,
is_dirty BOOLEAN DEFAULT 0,
server_name TEXT,
server_ip TEXT,
server_port INTEGER,
server_user TEXT,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)`).run();
db.prepare(`CREATE TABLE IF NOT EXISTS user_current_path (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL UNIQUE,
current_path TEXT NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)`).run();
db.prepare(`CREATE TABLE IF NOT EXISTS user_ssh_servers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
server_name TEXT NOT NULL,
server_ip TEXT NOT NULL,
server_port INTEGER DEFAULT 22,
username TEXT NOT NULL,
password TEXT,
ssh_key TEXT,
default_path TEXT DEFAULT '/',
created_at TEXT NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)`).run();
function getKeyAndIV() {
const key = crypto.createHash('sha256').update(SALT).digest();
const iv = Buffer.alloc(16, 0);
return { key, iv };
}
function encrypt(text) {
const { key, iv } = getKeyAndIV();
const cipher = crypto.createCipheriv('aes-256-ctr', key, iv);
let crypted = cipher.update(text, 'utf8', 'hex');
crypted += cipher.final('hex');
return crypted;
}
function decrypt(text) {
const { key, iv } = getKeyAndIV();
const decipher = crypto.createDecipheriv('aes-256-ctr', key, iv);
let dec = decipher.update(text, 'hex', 'utf8');
dec += decipher.final('utf8');
return dec;
}
function generateToken(user) {
return jwt.sign({ id: user.id, username: user.username }, JWT_SECRET, { expiresIn: '7d' });
}
function authMiddleware(req, res, next) {
const authHeader = req.headers['authorization'];
if (!authHeader) return res.status(401).json({ error: 'No token provided' });
const token = authHeader.split(' ')[1];
if (!token) return res.status(401).json({ error: 'Invalid token format' });
try {
const decoded = jwt.verify(token, JWT_SECRET);
req.user = decoded;
next();
} catch (err) {
return res.status(401).json({ error: 'Invalid or expired token' });
}
}
app.post('/register', async (req, res) => {
const { username, password } = req.body;
if (!username || !password) return res.status(400).json({ error: 'Username and password required' });
const settings = db.prepare('SELECT signup_enabled FROM settings WHERE id = 1').get();
if (!settings.signup_enabled) {
return res.status(403).json({ error: 'Signups are currently disabled' });
}
try {
const userCount = db.prepare('SELECT COUNT(*) as count FROM users').get().count;
const isFirstUser = userCount === 0;
const hash = await bcrypt.hash(password + SALT, 10);
const stmt = db.prepare('INSERT INTO users (username, password, created_at, is_admin) VALUES (?, ?, ?, ?)');
stmt.run(username, encrypt(hash), new Date().toISOString(), isFirstUser ? 1 : 0);
const user = db.prepare('SELECT * FROM users WHERE username = ?').get(username);
const token = generateToken(user);
return res.json({
token,
user: {
id: user.id,
username: user.username,
isAdmin: user.is_admin === 1
},
isFirstUser
});
} catch (err) {
if (err.code === 'SQLITE_CONSTRAINT') {
return res.status(409).json({ error: 'Username already exists' });
}
logger.error('Registration error:', err);
return res.status(500).json({ error: 'Registration failed' });
}
});
app.post('/login', async (req, res) => {
const { username, password } = req.body;
if (!username || !password) return res.status(401).json({ error: 'Username and password required' });
try {
const user = db.prepare('SELECT * FROM users WHERE username = ?').get(username);
if (!user) return res.status(401).json({ error: 'Invalid credentials' });
const hash = decrypt(user.password);
const valid = await bcrypt.compare(password + SALT, hash);
if (!valid) return res.status(401).json({ error: 'Invalid credentials' });
const token = generateToken(user);
return res.json({
token,
user: {
id: user.id,
username: user.username,
isAdmin: user.is_admin === 1
}
});
} catch (err) {
logger.error('Login error:', err);
return res.status(500).json({ error: 'Login failed' });
}
});
app.get('/profile', authMiddleware, (req, res) => {
const user = db.prepare('SELECT id, username, created_at, is_admin FROM users WHERE id = ?').get(req.user.id);
if (!user) return res.status(404).json({ error: 'User not found' });
return res.json({
user: {
id: user.id,
username: user.username,
created_at: user.created_at,
isAdmin: user.is_admin === 1
}
});
});
app.get('/check-first-user', (req, res) => {
const userCount = db.prepare('SELECT COUNT(*) as count FROM users').get().count;
return res.json({ isFirstUser: userCount === 0 });
});
app.get('/admin/settings', authMiddleware, (req, res) => {
const user = db.prepare('SELECT is_admin FROM users WHERE id = ?').get(req.user.id);
if (!user || !user.is_admin) return res.status(403).json({ error: 'Admin access required' });
const settings = db.prepare('SELECT signup_enabled FROM settings WHERE id = 1').get();
return res.json({ settings });
});
app.post('/admin/settings', authMiddleware, (req, res) => {
const user = db.prepare('SELECT is_admin FROM users WHERE id = ?').get(req.user.id);
if (!user || !user.is_admin) return res.status(403).json({ error: 'Admin access required' });
const { signup_enabled } = req.body;
if (typeof signup_enabled !== 'boolean') {
return res.status(400).json({ error: 'Invalid signup_enabled value' });
}
db.prepare('UPDATE settings SET signup_enabled = ? WHERE id = 1').run(signup_enabled ? 1 : 0);
return res.json({ message: 'Settings updated successfully' });
});
app.use('/file', authMiddleware);
app.use('/files', authMiddleware);
app.post('/user/data', authMiddleware, (req, res) => {
const { recentFiles, starredFiles, folderShortcuts, openTabs, currentPath, sshServers, theme } = req.body;
const userId = req.user.id;
try {
db.prepare('BEGIN').run();
if (recentFiles) {
db.prepare('DELETE FROM user_recent_files WHERE user_id = ?').run(userId);
const stmt = db.prepare('INSERT INTO user_recent_files (user_id, file_path, file_name, last_opened, server_name, server_ip, server_port, server_user, server_default_path) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)');
recentFiles.forEach(file => {
stmt.run(userId, file.path, file.name, file.lastOpened, file.serverName, file.serverIp, file.serverPort, file.serverUser, file.serverDefaultPath);
});
}
if (starredFiles) {
db.prepare('DELETE FROM user_starred_files WHERE user_id = ?').run(userId);
const stmt = db.prepare('INSERT INTO user_starred_files (user_id, file_path, file_name, last_opened, server_name, server_ip, server_port, server_user, server_default_path) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)');
starredFiles.forEach(file => {
stmt.run(userId, file.path, file.name, file.lastOpened, file.serverName, file.serverIp, file.serverPort, file.serverUser, file.serverDefaultPath);
});
}
if (folderShortcuts) {
db.prepare('DELETE FROM user_folder_shortcuts WHERE user_id = ?').run(userId);
const stmt = db.prepare('INSERT INTO user_folder_shortcuts (user_id, folder_path, folder_name, server_name, server_ip, server_port, server_user, server_default_path) VALUES (?, ?, ?, ?, ?, ?, ?, ?)');
folderShortcuts.forEach(folder => {
stmt.run(userId, folder.path, folder.name, folder.serverName, folder.serverIp, folder.serverPort, folder.serverUser, folder.serverDefaultPath);
});
}
if (openTabs) {
db.prepare('DELETE FROM user_open_tabs WHERE user_id = ?').run(userId);
const stmt = db.prepare('INSERT INTO user_open_tabs (user_id, tab_id, file_name, file_path, content, saved_content, is_dirty, server_name, server_ip, server_port, server_user) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)');
openTabs.forEach(tab => {
stmt.run(userId, tab.id, tab.name, tab.path, tab.content || '', tab.savedContent || '', tab.isDirty ? 1 : 0, tab.serverName, tab.serverIp, tab.serverPort, tab.serverUser);
});
}
if (currentPath) {
db.prepare('INSERT OR REPLACE INTO user_current_path (user_id, current_path) VALUES (?, ?)').run(userId, currentPath);
}
if (sshServers) {
db.prepare('DELETE FROM user_ssh_servers WHERE user_id = ?').run(userId);
const stmt = db.prepare('INSERT INTO user_ssh_servers (user_id, server_name, server_ip, server_port, username, password, ssh_key, default_path, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)');
sshServers.forEach(server => {
stmt.run(userId, server.name, server.ip, server.port || 22, server.user, server.password ? encrypt(server.password) : null, server.sshKey ? encrypt(server.sshKey) : null, server.defaultPath || '/', server.createdAt || new Date().toISOString());
});
}
if (theme) {
db.prepare('UPDATE users SET theme = ? WHERE id = ?').run(theme, userId);
}
db.prepare('COMMIT').run();
res.json({ message: 'User data saved successfully' });
} catch (err) {
db.prepare('ROLLBACK').run();
logger.error('Error saving user data:', err);
res.status(500).json({ error: 'Failed to save user data' });
}
});
app.get('/user/data', authMiddleware, (req, res) => {
const userId = req.user.id;
try {
const recentFiles = db.prepare('SELECT file_path as path, file_name as name, last_opened as lastOpened, server_name as serverName, server_ip as serverIp, server_port as serverPort, server_user as serverUser, server_default_path as serverDefaultPath FROM user_recent_files WHERE user_id = ?').all(userId);
const starredFiles = db.prepare('SELECT file_path as path, file_name as name, last_opened as lastOpened, server_name as serverName, server_ip as serverIp, server_port as serverPort, server_user as serverUser, server_default_path as serverDefaultPath FROM user_starred_files WHERE user_id = ?').all(userId);
const folderShortcuts = db.prepare('SELECT folder_path as path, folder_name as name, server_name as serverName, server_ip as serverIp, server_port as serverPort, server_user as serverUser, server_default_path as serverDefaultPath FROM user_folder_shortcuts WHERE user_id = ?').all(userId);
const openTabs = db.prepare('SELECT tab_id as id, file_name as name, file_path as path, content, saved_content as savedContent, is_dirty as isDirty, server_name as serverName, server_ip as serverIp, server_port as serverPort, server_user as serverUser FROM user_open_tabs WHERE user_id = ?').all(userId);
const currentPath = db.prepare('SELECT current_path FROM user_current_path WHERE user_id = ?').get(userId);
const sshServers = db.prepare('SELECT server_name as name, server_ip as ip, server_port as port, username as user, password, ssh_key as sshKey, default_path as defaultPath, created_at as createdAt FROM user_ssh_servers WHERE user_id = ?').all(userId);
const decryptedServers = sshServers.map(server => ({
...server,
password: server.password ? decrypt(server.password) : null,
sshKey: server.sshKey ? decrypt(server.sshKey) : null
}));
const userTheme = db.prepare('SELECT theme FROM users WHERE id = ?').get(userId)?.theme || 'vscode';
const data = {
recentFiles,
starredFiles,
folderShortcuts,
openTabs,
currentPath: currentPath?.current_path || '/',
sshServers: decryptedServers,
theme: userTheme
};
res.json(data);
} catch (err) {
logger.error('Error loading user data:', err);
res.status(500).json({ error: 'Failed to load user data' });
}
});
try {
db.prepare('ALTER TABLE users ADD COLUMN theme TEXT DEFAULT "vscode"').run();
} catch (e) {
if (!e.message.includes('duplicate column')) throw e;
}
app.listen(PORT, () => {
logger.info(`Database API listening at http://localhost:${PORT}`);
});

View File

@@ -0,0 +1,141 @@
const express = require('express');
const fs = require('fs');
const path = require('path');
const cors = require('cors');
const app = express();
const PORT = 8082;
app.use(cors({
origin: true,
credentials: true,
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization']
}));
app.use(express.json());
const getReadableTimestamp = () => {
return new Intl.DateTimeFormat('en-US', {
dateStyle: 'medium',
timeStyle: 'medium',
timeZone: 'UTC',
}).format(new Date());
};
const logger = {
info: (...args) => console.log(`📁 | 🔧 [${getReadableTimestamp()}] INFO:`, ...args),
error: (...args) => console.error(`📁 | ❌ [${getReadableTimestamp()}] ERROR:`, ...args),
warn: (...args) => console.warn(`📁 | ⚠️ [${getReadableTimestamp()}] WARN:`, ...args),
debug: (...args) => console.debug(`📁 | 🔍 [${getReadableTimestamp()}] DEBUG:`, ...args)
};
function normalizeFilePath(inputPath) {
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(path) {
try {
return fs.statSync(path).isDirectory();
} catch (e) {
return false;
}
}
app.get('/files', (req, res) => {
try {
const folderParam = req.query.folder || '';
const folderPath = normalizeFilePath(folderParam);
if (!fs.existsSync(folderPath) || !isDirectory(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) {
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 || '';
const fileName = req.query.name;
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) {
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 || '';
const fileName = req.query.name;
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) {
logger.error('Error in /file POST endpoint:', err);
res.status(500).json({ error: err.message });
}
});
app.listen(PORT, () => {
logger.info(`File manager API listening at http://localhost:${PORT}`);
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,380 @@
import React, { useState } from 'react';
import {
Stack,
Paper,
Text,
Group,
Button,
ActionIcon,
ScrollArea,
TextInput,
Divider,
SimpleGrid,
Loader
} from '@mantine/core';
import {
Star,
Folder,
File,
Trash2,
Plus,
History,
Bookmark,
Folders
} from 'lucide-react';
import { StarHoverableIcon } from './FileViewer.jsx';
function compareServers(a, b) {
if (!a && !b) return true;
if (!a || !b) return false;
if (a.isLocal && b.isLocal) return true;
return a.name === b.name && a.ip === b.ip && a.port === b.port && a.user === b.user;
}
export function HomeView({ onFileSelect, recentFiles, starredFiles, setStarredFiles, folderShortcuts, setFolderShortcuts, setFolder, setActiveTab, handleRemoveRecent, onSSHConnect, currentServer, isSSHConnecting }) {
const [newFolderPath, setNewFolderPath] = useState('');
const [activeSection, setActiveSection] = useState('recent');
const handleStarFile = (file) => {
const isStarred = starredFiles.some(f => f.path === file.path);
if (isStarred) {
setStarredFiles(starredFiles.filter(f => f.path !== file.path));
} else {
setStarredFiles([...starredFiles, file]);
}
};
const handleRemoveStarred = (file) => {
setStarredFiles(starredFiles.filter(f => f.path !== file.path));
};
const handleRemoveFolder = (folder) => {
setFolderShortcuts(folderShortcuts.filter(f => f.path !== folder.path));
};
const handleAddFolder = () => {
if (!newFolderPath) return;
setFolderShortcuts([...folderShortcuts, { path: newFolderPath, name: newFolderPath.split('/').pop(), server: currentServer }]);
setNewFolderPath('');
};
const getServerSpecificData = (data) => {
if (!currentServer) return [];
return data.filter(item => compareServers(item.server, currentServer));
};
const serverRecentFiles = getServerSpecificData(recentFiles);
const serverStarredFiles = getServerSpecificData(starredFiles);
const serverFolderShortcuts = getServerSpecificData(folderShortcuts);
const handleFileClick = async (file) => {
if (file.server && !file.server.isLocal) {
if (onSSHConnect && (!currentServer || !compareServers(currentServer, file.server))) {
const connected = await onSSHConnect(file.server);
if (!connected) {
return;
}
}
const pathParts = file.path.split('/').filter(Boolean);
const fileName = pathParts.pop() || '';
const folderPath = '/' + pathParts.join('/');
onFileSelect(fileName, folderPath, file.server, file.path);
} else {
let parentFolder;
if (navigator.platform.includes('Win') && file.path.includes(':')) {
const lastSlashIndex = file.path.lastIndexOf('/');
if (lastSlashIndex === -1) {
const driveLetter = file.path.substring(0, file.path.indexOf(':') + 1);
parentFolder = driveLetter + '/';
} else {
parentFolder = file.path.substring(0, lastSlashIndex + 1);
}
} else {
const lastSlashIndex = file.path.lastIndexOf('/');
parentFolder = lastSlashIndex === -1 ? '/' : file.path.substring(0, lastSlashIndex + 1);
}
onFileSelect(file.name, parentFolder);
}
};
const FileItem = ({ file, onStar, onRemove, showRemove }) => {
const parentFolder = file.path.substring(0, file.path.lastIndexOf('/')) || '/';
const isSSHFile = file.server;
return (
<Paper
p="xs"
style={{
backgroundColor: '#36414C',
border: '1px solid #4A5568',
cursor: 'pointer',
height: '100%',
maxWidth: '100%',
transition: 'background 0.2s',
display: 'flex',
alignItems: 'center',
paddingRight: 0,
}}
onMouseOver={e => e.currentTarget.style.backgroundColor = '#4A5568'}
onMouseOut={e => e.currentTarget.style.backgroundColor = '#36414C'}
onClick={() => handleFileClick(file)}
>
<div style={{
display: 'flex',
alignItems: 'center',
flex: 1,
minWidth: 0,
maxWidth: 'calc(100% - 40px)',
overflow: 'hidden',
}}>
<File size={16} color={isSSHFile ? "#4299E1" : "#A0AEC0"} style={{ userSelect: 'none', flexShrink: 0 }} />
<div style={{ flex: 1, minWidth: 0, marginLeft: 8 }}>
<Text size="sm" color="white" style={{ lineHeight: 1.2, wordBreak: 'break-word', whiteSpace: 'normal', userSelect: 'none', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{file.name}
</Text>
<Text size="xs" color="dimmed" style={{ lineHeight: 1.2, marginTop: 2, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{file.path}</Text>
</div>
</div>
<div style={{
display: 'flex',
alignItems: 'center',
gap: 4,
paddingLeft: 4
}}>
<ActionIcon
variant="subtle"
color="yellow"
style={{ borderRadius: '50%', marginLeft: 0, background: 'none', width: 24, height: 24, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 0 }}
onClick={e => {
e.stopPropagation();
onStar(file);
}}
>
{starredFiles.some(f => f.path === file.path) ? (
<Star size={16} fill="currentColor" />
) : (
<StarHoverableIcon size={16} />
)}
</ActionIcon>
{showRemove && (
<ActionIcon
variant="subtle"
color="red"
style={{ borderRadius: '50%', marginLeft: 0, background: 'none', width: 24, height: 24, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 0 }}
onClick={e => {
e.stopPropagation();
onRemove(file);
}}
>
<Trash2 size={16} />
</ActionIcon>
)}
</div>
</Paper>
);
};
const FolderItem = ({ folder, onRemove }) => (
<Paper
p="xs"
style={{
backgroundColor: '#36414C',
border: '1px solid #4A5568',
cursor: 'pointer',
height: '100%',
maxWidth: '100%',
transition: 'background 0.2s',
}}
onMouseOver={e => e.currentTarget.style.backgroundColor = '#4A5568'}
onMouseOut={e => e.currentTarget.style.backgroundColor = '#36414C'}
onClick={() => {
setFolder(folder.path);
}}
>
<Group spacing={4} align="flex-start" noWrap>
<Folder size={16} color="#4299E1" style={{ marginTop: 2 }} />
<div style={{ flex: 1, minWidth: 0 }}>
<Text size="sm" color="white" style={{ lineHeight: 1.2, wordBreak: 'break-word', whiteSpace: 'normal', userSelect: 'none', overflow: 'hidden', textOverflow: 'ellipsis' }}>{folder.name}</Text>
<Text size="xs" color="dimmed" style={{ lineHeight: 1.2, marginTop: 2, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{folder.path}</Text>
</div>
<ActionIcon
variant="subtle"
color="red"
style={{ borderRadius: '50%', marginLeft: 0, background: 'none', width: 24, height: 24, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 0 }}
onClick={e => {
e.stopPropagation();
onRemove(folder);
}}
>
<Trash2 size={16} />
</ActionIcon>
</Group>
</Paper>
);
return (
<Stack
h="100%"
spacing="md"
p="md"
style={{
color: 'white'
}}
>
{!currentServer && (
<Paper p="md" style={{ backgroundColor: '#2F3740', border: '1px solid #4A5568' }}>
<Text color="dimmed" align="center" size="lg">
Please select a server from the sidebar to view your files
</Text>
</Paper>
)}
{currentServer && (
<>
<Paper p="xs" style={{ backgroundColor: '#2F3740', border: '1px solid #4A5568' }}>
<Text color="white" size="sm" weight={500}>
Connected to: {currentServer.name} ({currentServer.user}@{currentServer.ip}:{currentServer.port})
</Text>
</Paper>
{isSSHConnecting ? (
<Paper p="md" style={{ backgroundColor: '#2F3740', border: '1px solid #4A5568' }}>
<Group justify="center" spacing="md">
<Loader size="sm" color="#4299E1" />
<Text color="dimmed" align="center" size="lg">
Connecting to SSH server...
</Text>
</Group>
</Paper>
) : (
<>
<Group spacing="md" mb="md">
<Button
variant="filled"
color="blue"
leftSection={<History size={18} />}
onClick={() => setActiveSection('recent')}
style={{ backgroundColor: activeSection === 'recent' ? '#36414C' : '#4A5568', color: 'white', borderColor: '#4A5568', transition: 'background 0.2s' }}
onMouseOver={e => e.currentTarget.style.backgroundColor = activeSection === 'recent' ? '#36414C' : '#36414C'}
onMouseOut={e => e.currentTarget.style.backgroundColor = activeSection === 'recent' ? '#36414C' : '#4A5568'}
>
Recent
</Button>
<Button
variant="filled"
color="yellow"
leftSection={<Bookmark size={18} />}
onClick={() => setActiveSection('starred')}
style={{ backgroundColor: activeSection === 'starred' ? '#36414C' : '#4A5568', color: 'white', borderColor: '#4A5568', transition: 'background 0.2s' }}
onMouseOver={e => e.currentTarget.style.backgroundColor = activeSection === 'starred' ? '#36414C' : '#36414C'}
onMouseOut={e => e.currentTarget.style.backgroundColor = activeSection === 'starred' ? '#36414C' : '#4A5568'}
>
Starred
</Button>
<Button
variant="filled"
color="teal"
leftSection={<Folders size={18} />}
onClick={() => setActiveSection('folders')}
style={{ backgroundColor: activeSection === 'folders' ? '#36414C' : '#4A5568', color: 'white', borderColor: '#4A5568', transition: 'background 0.2s' }}
onMouseOver={e => e.currentTarget.style.backgroundColor = activeSection === 'folders' ? '#36414C' : '#36414C'}
onMouseOut={e => e.currentTarget.style.backgroundColor = activeSection === 'folders' ? '#36414C' : '#4A5568'}
>
Folder Shortcuts
</Button>
</Group>
{activeSection === 'recent' && (
<div style={{ height: 'calc(100vh - 200px)', overflow: 'hidden' }}>
<SimpleGrid cols={3} spacing="md">
{serverRecentFiles.length === 0 ? (
<Text color="dimmed" align="center" style={{ gridColumn: '1 / -1', padding: '2rem' }}>No recent files</Text>
) : (
serverRecentFiles.map(file => (
<FileItem
key={file.path}
file={file}
onStar={handleStarFile}
onRemove={handleRemoveRecent}
showRemove={true}
/>
))
)}
</SimpleGrid>
</div>
)}
{activeSection === 'starred' && (
<div style={{ height: 'calc(100vh - 200px)', overflow: 'hidden' }}>
<SimpleGrid cols={3} spacing="md">
{serverStarredFiles.length === 0 ? (
<Text color="dimmed" align="center" style={{ gridColumn: '1 / -1', padding: '2rem' }}>No starred files</Text>
) : (
serverStarredFiles.map(file => (
<FileItem
key={file.path}
file={file}
onStar={handleStarFile}
showRemove={false}
/>
))
)}
</SimpleGrid>
</div>
)}
{activeSection === 'folders' && (
<Stack spacing="md">
<Group>
<TextInput
placeholder="Enter folder path"
value={newFolderPath}
onChange={(e) => setNewFolderPath(e.target.value)}
style={{ flex: 1 }}
styles={{
input: {
backgroundColor: '#36414C',
borderColor: '#4A5568',
color: 'white',
'&::placeholder': {
color: '#A0AEC0'
}
}
}}
/>
<Button
leftSection={<Plus size={16} />}
onClick={handleAddFolder}
variant="filled"
color="blue"
style={{
backgroundColor: '#36414C',
border: '1px solid #4A5568',
'&:hover': {
backgroundColor: '#4A5568'
}
}}
>
Add
</Button>
</Group>
<Divider color="#4A5568" />
<div style={{ height: 'calc(100vh - 280px)', overflow: 'hidden' }}>
<SimpleGrid cols={3} spacing="md">
{serverFolderShortcuts.length === 0 ? (
<Text color="dimmed" align="center" style={{ gridColumn: '1 / -1', padding: '2rem' }}>No folder shortcuts</Text>
) : (
serverFolderShortcuts.map(folder => (
<FolderItem
key={folder.path}
folder={folder}
onRemove={handleRemoveFolder}
/>
))
)}
</SimpleGrid>
</div>
</Stack>
)}
</>
)}
</>
)}
</Stack>
);
}

View File

@@ -0,0 +1,358 @@
const express = require('express');
const http = require('http');
const cors = require("cors");
const bcrypt = require("bcrypt");
const SSHClient = require("ssh2").Client;
const app = express();
const PORT = 8083;
let sshConnection = null;
let isConnected = false;
app.use(cors({
origin: true,
credentials: true,
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization']
}));
app.use(express.json());
const getReadableTimestamp = () => {
return new Intl.DateTimeFormat('en-US', {
dateStyle: 'medium',
timeStyle: 'medium',
timeZone: 'UTC',
}).format(new Date());
};
const logger = {
info: (...args) => console.log(`💻 | 🔧 [${getReadableTimestamp()}] INFO:`, ...args),
error: (...args) => console.error(`💻 | ❌ [${getReadableTimestamp()}] ERROR:`, ...args),
warn: (...args) => console.warn(`💻 | ⚠️ [${getReadableTimestamp()}] WARN:`, ...args),
debug: (...args) => console.debug(`💻 | 🔍 [${getReadableTimestamp()}] DEBUG:`, ...args)
};
const closeSSHConnection = () => {
if (sshConnection && isConnected) {
try {
sshConnection.end();
sshConnection = null;
isConnected = false;
} catch (err) {
logger.error('Error closing SSH connection:', err.message);
}
}
};
const executeSSHCommand = (command) => {
return new Promise((resolve, reject) => {
if (!sshConnection || !isConnected) {
return reject(new Error('SSH connection not established'));
}
sshConnection.exec(command, (err, stream) => {
if (err) {
logger.error('Error executing SSH command:', err.message);
return reject(err);
}
let data = '';
let error = '';
stream.on('data', (chunk) => {
data += chunk.toString();
});
stream.stderr.on('data', (chunk) => {
error += chunk.toString();
});
stream.on('close', (code) => {
if (code !== 0) {
logger.error(`SSH command failed with code ${code}:`, error);
return reject(new Error(`Command failed with code ${code}: ${error}`));
}
resolve(data.trim());
});
});
});
};
app.post('/sshConnect', async (req, res) => {
try {
const hostConfig = req.body;
if (!hostConfig || !hostConfig.ip || !hostConfig.user) {
return res.status(400).json({
status: 'error',
message: 'Missing required host configuration (ip, user)'
});
}
closeSSHConnection();
sshConnection = new SSHClient();
const connectionConfig = {
host: hostConfig.ip,
port: hostConfig.port || 22,
username: hostConfig.user,
readyTimeout: 20000,
keepaliveInterval: 10000,
keepaliveCountMax: 3
};
if (hostConfig.sshKey) {
connectionConfig.privateKey = hostConfig.sshKey;
} else if (hostConfig.password) {
connectionConfig.password = hostConfig.password;
} else {
return res.status(400).json({
status: 'error',
message: 'Either password or SSH key must be provided'
});
}
sshConnection.on('ready', () => {
isConnected = true;
});
sshConnection.on('error', (err) => {
logger.error('SSH connection error:', err.message);
isConnected = false;
});
sshConnection.on('close', () => {
isConnected = false;
});
sshConnection.on('end', () => {
isConnected = false;
});
sshConnection.connect(connectionConfig);
await new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error('SSH connection timeout'));
}, 20000);
sshConnection.once('ready', () => {
clearTimeout(timeout);
resolve();
});
sshConnection.once('error', (err) => {
clearTimeout(timeout);
reject(err);
});
});
return res.status(200).json({
status: 'success',
message: 'SSH connection established successfully'
});
} catch (error) {
logger.error('SSH connection failed:', error.message);
closeSSHConnection();
return res.status(500).json({
status: 'error',
message: `SSH connection failed: ${error.message}`
});
}
});
app.get('/listFiles', async (req, res) => {
try {
const { path = '/' } = req.query;
if (!sshConnection || !isConnected) {
return res.status(400).json({
status: 'error',
message: 'SSH connection not established. Please connect first.'
});
}
const lsCommand = `ls -la "${path}"`;
const result = await executeSSHCommand(lsCommand);
const lines = result.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+/);
if (parts.length >= 9) {
const permissions = parts[0];
const links = parseInt(parts[1]) || 0;
const owner = parts[2];
const group = parts[3];
const size = parseInt(parts[4]) || 0;
const month = parts[5];
const day = parseInt(parts[6]) || 0;
const timeOrYear = parts[7];
const name = parts.slice(8).join(' ');
const isDirectory = permissions.startsWith('d');
const isLink = permissions.startsWith('l');
files.push({
name: name,
type: isDirectory ? 'directory' : (isLink ? 'link' : 'file'),
size: size,
permissions: permissions,
owner: owner,
group: group,
modified: `${month} ${day} ${timeOrYear}`,
isDirectory: isDirectory,
isLink: isLink
});
}
}
return res.status(200).json({
status: 'success',
path: path,
files: files,
totalCount: files.length
});
} catch (error) {
logger.error('Error listing files:', error.message);
return res.status(500).json({
status: 'error',
message: `Failed to list files: ${error.message}`
});
}
});
app.post('/sshDisconnect', async (req, res) => {
try {
closeSSHConnection();
return res.status(200).json({
status: 'success',
message: 'SSH connection disconnected successfully'
});
} catch (error) {
logger.error('Error disconnecting SSH:', error.message);
return res.status(500).json({
status: 'error',
message: `Failed to disconnect: ${error.message}`
});
}
});
app.get('/sshStatus', async (req, res) => {
return res.status(200).json({
status: 'success',
connected: isConnected,
hasConnection: !!sshConnection
});
});
app.get('/readFile', async (req, res) => {
try {
const { path: filePath } = req.query;
if (!sshConnection || !isConnected) {
return res.status(400).json({
status: 'error',
message: 'SSH connection not established. Please connect first.'
});
}
if (!filePath) {
return res.status(400).json({
status: 'error',
message: 'File path is required'
});
}
const catCommand = `cat "${filePath}"`;
const result = await executeSSHCommand(catCommand);
return res.status(200).json({
status: 'success',
content: result,
path: filePath
});
} catch (error) {
logger.error('Error reading file:', error.message);
return res.status(500).json({
status: 'error',
message: `Failed to read file: ${error.message}`
});
}
});
app.post('/writeFile', async (req, res) => {
try {
const { path: filePath, content } = req.body;
if (!sshConnection || !isConnected) {
return res.status(400).json({
status: 'error',
message: 'SSH connection not established. Please connect first.'
});
}
if (!filePath) {
return res.status(400).json({
status: 'error',
message: 'File path is required'
});
}
if (content === undefined) {
return res.status(400).json({
status: 'error',
message: 'File content is required'
});
}
const tempFile = `/tmp/temp_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
const echoCommand = `echo '${content.replace(/'/g, "'\"'\"'")}' > "${tempFile}"`;
await executeSSHCommand(echoCommand);
const mvCommand = `mv "${tempFile}" "${filePath}"`;
await executeSSHCommand(mvCommand);
return res.status(200).json({
status: 'success',
message: 'File written successfully',
path: filePath
});
} catch (error) {
logger.error('Error writing file:', error.message);
return res.status(500).json({
status: 'error',
message: `Failed to write file: ${error.message}`
});
}
});
process.on('SIGINT', () => {
closeSSHConnection();
process.exit(0);
});
process.on('SIGTERM', () => {
closeSSHConnection();
process.exit(0);
});
app.listen(PORT, () => {
logger.info(`SSH API listening at http://localhost:${PORT}`);
});

View File

@@ -0,0 +1,148 @@
import React from 'react';
import { Button } from '@mantine/core';
import { Home } from 'lucide-react';
export function TabList({ tabs, activeTab, setActiveTab, closeTab, onHomeClick }) {
return (
<div style={{
height: '40px',
backgroundColor: '#2F3740',
borderRadius: '4px',
overflow: 'hidden',
flex: 1,
margin: '0 8px',
display: 'flex',
alignItems: 'center'
}}>
<div style={{
height: '100%',
display: 'flex',
alignItems: 'center',
padding: '0 4px',
overflowX: 'auto',
width: '100%',
scrollbarWidth: 'thin',
scrollbarColor: '#4A5568 #2F3740'
}}>
<style>
{`
div::-webkit-scrollbar {
height: 6px;
}
div::-webkit-scrollbar-track {
background: #2F3740;
}
div::-webkit-scrollbar-thumb {
background: #4A5568;
border-radius: 3px;
}
`}
</style>
<div
style={{
display: 'flex',
alignItems: 'center',
backgroundColor: activeTab === 'home' ? '#36414C' : '#2F3740',
borderRadius: '4px',
height: '32px',
minWidth: '48px',
marginRight: '4px',
border: '1px solid #4A5568',
overflow: 'hidden',
flexShrink: 0
}}
>
<Button
onClick={onHomeClick}
variant="subtle"
color="gray"
style={{
height: '100%',
padding: '0 8px',
backgroundColor: 'transparent',
color: 'white',
border: 'none',
minWidth: '48px',
borderRadius: 0,
transition: 'background 0.2s',
}}
onMouseOver={e => e.currentTarget.style.backgroundColor = '#4A5568'}
onMouseOut={e => e.currentTarget.style.backgroundColor = 'transparent'}
>
<Home size={16} />
</Button>
</div>
{tabs.map((tab, i) => {
const isActive = tab.id === activeTab;
return (
<div
key={tab.id}
style={{
display: 'flex',
alignItems: 'center',
backgroundColor: isActive ? '#36414C' : '#2F3740',
borderRadius: '4px',
height: '32px',
minWidth: '120px',
maxWidth: '200px',
marginRight: '4px',
border: '1px solid #4A5568',
overflow: 'hidden',
flexShrink: 0
}}
>
<Button
onClick={() => setActiveTab(tab.id)}
variant="subtle"
color="gray"
style={{
flex: 1,
height: '100%',
padding: '0 8px',
backgroundColor: 'transparent',
color: 'white',
border: 'none',
textAlign: 'left',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
borderRadius: 0,
transition: 'background 0.2s',
}}
onMouseOver={e => e.currentTarget.style.backgroundColor = '#4A5568'}
onMouseOut={e => e.currentTarget.style.backgroundColor = 'transparent'}
>
{tab.name}
</Button>
<div style={{
width: '1px',
height: '16px',
backgroundColor: '#4A5568',
margin: '0 4px'
}} />
<Button
onClick={() => closeTab(tab.id)}
variant="subtle"
color="gray"
style={{
height: '100%',
padding: '0 8px',
backgroundColor: 'transparent',
color: 'white',
border: 'none',
minWidth: '32px',
borderRadius: 0,
transition: 'background 0.2s',
}}
onMouseOver={e => e.currentTarget.style.backgroundColor = '#4A5568'}
onMouseOut={e => e.currentTarget.style.backgroundColor = 'transparent'}
>
×
</Button>
</div>
);
})}
</div>
</div>
);
}

View File

@@ -0,0 +1 @@

View File

@@ -3,9 +3,19 @@ import bodyParser from 'body-parser';
import userRoutes from './routes/users.js';
import sshRoutes from './routes/ssh.js';
import sshTunnelRoutes from './routes/ssh_tunnel.js';
import configEditorRoutes from './routes/config_editor.js';
import chalk from 'chalk';
import cors from 'cors';
// CORS for local dev
const app = express();
app.use(cors({
origin: 'http://localhost:5173',
credentials: true,
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization']
}));
// Custom logger (adapted from starter.ts, with a database icon)
const dbIconSymbol = '🗄️';
const getTimeStamp = (): string => chalk.gray(`[${new Date().toLocaleTimeString()}]`);
@@ -33,13 +43,6 @@ const logger = {
}
};
const app = express();
app.use(cors({
origin: 'http://localhost:5173',
credentials: true
}));
app.use(bodyParser.json());
app.get('/health', (req, res) => {
@@ -49,6 +52,7 @@ app.get('/health', (req, res) => {
app.use('/users', userRoutes);
app.use('/ssh', sshRoutes);
app.use('/ssh_tunnel', sshTunnelRoutes);
app.use('/config_editor', configEditorRoutes);
app.use((err: unknown, req: express.Request, res: express.Response, next: express.NextFunction) => {
logger.error('Unhandled error:', err);

View File

@@ -37,7 +37,6 @@ if (!fs.existsSync(dbDir)) {
}
const sqlite = new Database('./db/data/db.sqlite');
logger.success('Database connection established');
sqlite.exec(`
CREATE TABLE IF NOT EXISTS users (
@@ -62,6 +61,26 @@ CREATE TABLE IF NOT EXISTS ssh_data (
key_type TEXT,
save_auth_method INTEGER,
is_pinned INTEGER,
default_path TEXT,
FOREIGN KEY(user_id) REFERENCES users(id)
);
CREATE TABLE IF NOT EXISTS config_ssh_data (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
name TEXT,
folder TEXT,
tags TEXT,
ip TEXT NOT NULL,
port INTEGER NOT NULL,
username TEXT,
password TEXT,
auth_method TEXT,
key TEXT,
key_password TEXT,
key_type TEXT,
save_auth_method INTEGER,
is_pinned INTEGER,
default_path TEXT,
FOREIGN KEY(user_id) REFERENCES users(id)
);
CREATE TABLE IF NOT EXISTS ssh_tunnel_data (
@@ -98,6 +117,18 @@ CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS config_editor_data (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
type TEXT NOT NULL,
name TEXT,
path TEXT NOT NULL,
server TEXT,
last_opened TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
FOREIGN KEY(user_id) REFERENCES users(id)
);
`);
try {
sqlite.prepare('SELECT is_admin FROM users LIMIT 1').get();

View File

@@ -23,6 +23,7 @@ export const sshData = sqliteTable('ssh_data', {
keyType: text('key_type'), // Type of SSH key (RSA, ED25519, etc.)
saveAuthMethod: integer('save_auth_method', { mode: 'boolean' }),
isPinned: integer('is_pinned', { mode: 'boolean' }),
defaultPath: text('default_path'), // Default path for SSH connection
});
export const sshTunnelData = sqliteTable('ssh_tunnel_data', {
@@ -58,4 +59,35 @@ export const sshTunnelData = sqliteTable('ssh_tunnel_data', {
export const settings = sqliteTable('settings', {
key: text('key').primaryKey(),
value: text('value').notNull(),
});
export const configEditorData = sqliteTable('config_editor_data', {
id: integer('id').primaryKey({ autoIncrement: true }),
userId: text('user_id').notNull().references(() => users.id),
type: text('type').notNull(), // 'recent' | 'pinned' | 'shortcut'
name: text('name'),
path: text('path').notNull(),
server: text('server', { length: 2048 }), // JSON stringified server info (if SSH)
lastOpened: text('last_opened'), // ISO string (for recent)
createdAt: text('created_at').notNull(),
updatedAt: text('updated_at').notNull(),
});
export const configSshData = sqliteTable('config_ssh_data', {
id: integer('id').primaryKey({ autoIncrement: true }),
userId: text('user_id').notNull().references(() => users.id),
name: text('name'),
folder: text('folder'),
tags: text('tags'),
ip: text('ip').notNull(),
port: integer('port').notNull(),
username: text('username'),
password: text('password'),
authMethod: text('auth_method'),
key: text('key', { length: 8192 }),
keyPassword: text('key_password'),
keyType: text('key_type'),
saveAuthMethod: integer('save_auth_method', { mode: 'boolean' }),
isPinned: integer('is_pinned', { mode: 'boolean' }),
defaultPath: text('default_path'),
});

View File

@@ -0,0 +1,317 @@
import express from 'express';
import { db } from '../db/index.js';
import { configEditorData, configSshData } from '../db/schema.js';
import { eq, and } from 'drizzle-orm';
import type { Request, Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken';
const router = express.Router();
// --- JWT Auth Middleware ---
interface JWTPayload {
userId: string;
iat?: number;
exp?: number;
}
function authenticateJWT(req: Request, res: Response, next: NextFunction) {
const authHeader = req.headers['authorization'];
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Missing or invalid Authorization header' });
}
const token = authHeader.split(' ')[1];
const jwtSecret = process.env.JWT_SECRET || 'secret';
try {
const payload = jwt.verify(token, jwtSecret) as JWTPayload;
(req as any).userId = payload.userId;
next();
} catch (err) {
return res.status(401).json({ error: 'Invalid or expired token' });
}
}
// --- Config Data Endpoints (DB-backed, per user) ---
router.get('/recent', authenticateJWT, async (req, res) => {
const userId = (req as any).userId;
try {
const data = await db.select().from(configEditorData).where(and(eq(configEditorData.userId, userId), eq(configEditorData.type, 'recent')));
res.json(data);
} catch (err) {
res.status(500).json({ error: 'Failed to fetch recent files' });
}
});
router.post('/recent', authenticateJWT, async (req, res) => {
const userId = (req as any).userId;
const { name, path: filePath, server, lastOpened } = req.body;
if (!filePath) return res.status(400).json({ error: 'Missing path' });
try {
const now = new Date().toISOString();
await db.insert(configEditorData).values({
userId,
type: 'recent',
name,
path: filePath,
server: server ? JSON.stringify(server) : null,
lastOpened: lastOpened || now,
createdAt: now,
updatedAt: now,
});
res.json({ message: 'Added to recent' });
} catch (err) {
res.status(500).json({ error: 'Failed to add to recent' });
}
});
router.get('/pinned', authenticateJWT, async (req, res) => {
const userId = (req as any).userId;
try {
const data = await db.select().from(configEditorData).where(and(eq(configEditorData.userId, userId), eq(configEditorData.type, 'pinned')));
res.json(data);
} catch (err) {
res.status(500).json({ error: 'Failed to fetch pinned files' });
}
});
router.post('/pinned', authenticateJWT, async (req, res) => {
const userId = (req as any).userId;
const { name, path: filePath, server } = req.body;
if (!filePath) return res.status(400).json({ error: 'Missing path' });
try {
const now = new Date().toISOString();
await db.insert(configEditorData).values({
userId,
type: 'pinned',
name,
path: filePath,
server: server ? JSON.stringify(server) : null,
createdAt: now,
updatedAt: now,
});
res.json({ message: 'Added to pinned' });
} catch (err) {
res.status(500).json({ error: 'Failed to add to pinned' });
}
});
router.get('/shortcuts', authenticateJWT, async (req, res) => {
const userId = (req as any).userId;
try {
const data = await db.select().from(configEditorData).where(and(eq(configEditorData.userId, userId), eq(configEditorData.type, 'shortcut')));
res.json(data);
} catch (err) {
res.status(500).json({ error: 'Failed to fetch shortcuts' });
}
});
router.post('/shortcuts', authenticateJWT, async (req, res) => {
const userId = (req as any).userId;
const { name, path: folderPath, server } = req.body;
if (!folderPath) return res.status(400).json({ error: 'Missing path' });
try {
const now = new Date().toISOString();
await db.insert(configEditorData).values({
userId,
type: 'shortcut',
name,
path: folderPath,
server: server ? JSON.stringify(server) : null,
createdAt: now,
updatedAt: now,
});
res.json({ message: 'Added to shortcuts' });
} catch (err) {
res.status(500).json({ error: 'Failed to add to shortcuts' });
}
});
// DELETE /config_editor/shortcuts
router.delete('/shortcuts', authenticateJWT, async (req, res) => {
const userId = (req as any).userId;
const { path } = req.body;
if (!path) return res.status(400).json({ error: 'Missing path' });
try {
await db.delete(configEditorData)
.where(and(eq(configEditorData.userId, userId), eq(configEditorData.type, 'shortcut'), eq(configEditorData.path, path)));
res.json({ message: 'Shortcut removed' });
} catch (err) {
res.status(500).json({ error: 'Failed to remove shortcut' });
}
});
// POST /config_editor/shortcuts/delete (for compatibility)
router.post('/shortcuts/delete', authenticateJWT, async (req, res) => {
const userId = (req as any).userId;
const { path } = req.body;
if (!path) return res.status(400).json({ error: 'Missing path' });
try {
await db.delete(configEditorData)
.where(and(eq(configEditorData.userId, userId), eq(configEditorData.type, 'shortcut'), eq(configEditorData.path, path)));
res.json({ message: 'Shortcut removed' });
} catch (err) {
res.status(500).json({ error: 'Failed to remove shortcut' });
}
});
// --- Local Default Path Endpoints ---
// GET /config_editor/local_default_path
router.get('/local_default_path', authenticateJWT, async (req, res) => {
const userId = (req as any).userId;
try {
const row = await db.select().from(configEditorData)
.where(and(eq(configEditorData.userId, userId), eq(configEditorData.type, 'local_default_path')))
.then(rows => rows[0]);
res.json({ defaultPath: row?.path || '/' });
} catch (err) {
res.status(500).json({ error: 'Failed to fetch local default path' });
}
});
// POST /config_editor/local_default_path
router.post('/local_default_path', authenticateJWT, async (req, res) => {
const userId = (req as any).userId;
const { defaultPath } = req.body;
if (!defaultPath) return res.status(400).json({ error: 'Missing defaultPath' });
try {
const now = new Date().toISOString();
// Upsert: delete old, insert new
await db.delete(configEditorData)
.where(and(eq(configEditorData.userId, userId), eq(configEditorData.type, 'local_default_path')));
await db.insert(configEditorData).values({
userId,
type: 'local_default_path',
name: 'Local Files',
path: defaultPath,
createdAt: now,
updatedAt: now,
});
res.json({ message: 'Local default path saved' });
} catch (err) {
res.status(500).json({ error: 'Failed to save local default path' });
}
});
// --- SSH Connection CRUD for Config Editor ---
// GET /config_editor/ssh/host
router.get('/ssh/host', authenticateJWT, async (req, res) => {
const userId = (req as any).userId;
if (!userId) {
return res.status(400).json({ error: 'Invalid userId' });
}
try {
const data = await db.select().from(configSshData).where(eq(configSshData.userId, userId));
res.json(data);
} catch (err) {
res.status(500).json({ error: 'Failed to fetch SSH hosts' });
}
});
// POST /config_editor/ssh/host
router.post('/ssh/host', authenticateJWT, async (req, res) => {
const userId = (req as any).userId;
const { name, folder, tags, ip, port, username, password, sshKey, keyPassword, keyType, isPinned, defaultPath, authMethod } = req.body;
if (!userId || !ip || !port) {
return res.status(400).json({ error: 'Invalid SSH data' });
}
const sshDataObj: any = {
userId,
name,
folder,
tags: Array.isArray(tags) ? tags.join(',') : tags,
ip,
port,
username,
authMethod,
isPinned: isPinned ? 1 : 0,
defaultPath: defaultPath || null,
};
if (authMethod === 'password') {
sshDataObj.password = password;
sshDataObj.key = null;
sshDataObj.keyPassword = null;
sshDataObj.keyType = null;
} else if (authMethod === 'key') {
sshDataObj.key = sshKey;
sshDataObj.keyPassword = keyPassword;
sshDataObj.keyType = keyType;
sshDataObj.password = null;
}
try {
await db.insert(configSshData).values(sshDataObj);
res.json({ message: 'SSH host created' });
} catch (err) {
res.status(500).json({ error: 'Failed to create SSH host' });
}
});
// PUT /config_editor/ssh/host/:id
router.put('/ssh/host/:id', authenticateJWT, async (req, res) => {
const userId = (req as any).userId;
const { id } = req.params;
const { name, folder, tags, ip, port, username, password, sshKey, keyPassword, keyType, isPinned, defaultPath, authMethod } = req.body;
if (!userId || !ip || !port || !id) {
return res.status(400).json({ error: 'Invalid SSH data' });
}
const sshDataObj: any = {
name,
folder,
tags: Array.isArray(tags) ? tags.join(',') : tags,
ip,
port,
username,
authMethod,
isPinned: isPinned ? 1 : 0,
defaultPath: defaultPath || null,
};
if (authMethod === 'password') {
sshDataObj.password = password;
sshDataObj.key = null;
sshDataObj.keyPassword = null;
sshDataObj.keyType = null;
} else if (authMethod === 'key') {
sshDataObj.key = sshKey;
sshDataObj.keyPassword = keyPassword;
sshDataObj.keyType = keyType;
sshDataObj.password = null;
}
try {
await db.update(configSshData)
.set(sshDataObj)
.where(and(eq(configSshData.id, Number(id)), eq(configSshData.userId, userId)));
res.json({ message: 'SSH host updated' });
} catch (err) {
res.status(500).json({ error: 'Failed to update SSH host' });
}
});
// --- SSH Connection CRUD (reuse /ssh/host endpoints, or proxy) ---
router.delete('/ssh/host/:id', authenticateJWT, async (req, res) => {
const userId = (req as any).userId;
const { id } = req.params;
if (!userId || !id) {
return res.status(400).json({ error: 'Invalid userId or id' });
}
try {
await db.delete(configSshData)
.where(and(eq(configSshData.id, Number(id)), eq(configSshData.userId, userId)));
res.json({ message: 'SSH host deleted' });
} catch (err) {
res.status(500).json({ error: 'Failed to delete SSH host' });
}
});
// GET /config_editor/ssh/folders
router.get('/ssh/folders', authenticateJWT, async (req, res) => {
const userId = (req as any).userId;
if (!userId) {
return res.status(400).json({ error: 'Invalid userId' });
}
try {
const data = await db
.select({ folder: configSshData.folder })
.from(configSshData)
.where(eq(configSshData.userId, userId));
const folderCounts: Record<string, number> = {};
data.forEach(d => {
if (d.folder && d.folder.trim() !== '') {
folderCounts[d.folder] = (folderCounts[d.folder] || 0) + 1;
}
});
const folders = Object.keys(folderCounts).filter(folder => folderCounts[folder] > 0);
res.json(folders);
} catch (err) {
res.status(500).json({ error: 'Failed to fetch SSH folders' });
}
});
export default router;

View File

@@ -69,7 +69,7 @@ function authenticateJWT(req: Request, res: Response, next: NextFunction) {
// Route: Create SSH data (requires JWT)
// POST /ssh/host
router.post('/host', authenticateJWT, async (req: Request, res: Response) => {
const { name, folder, tags, ip, port, username, password, authMethod, key, keyPassword, keyType, saveAuthMethod, isPinned } = req.body;
const { name, folder, tags, ip, port, username, password, authMethod, key, keyPassword, keyType, saveAuthMethod, isPinned, defaultPath } = req.body;
const userId = (req as any).userId;
if (!isNonEmptyString(userId) || !isNonEmptyString(ip) || !isValidPort(port)) {
logger.warn('Invalid SSH data input');
@@ -87,6 +87,7 @@ router.post('/host', authenticateJWT, async (req: Request, res: Response) => {
authMethod,
saveAuthMethod: saveAuthMethod ? 1 : 0,
isPinned: isPinned ? 1 : 0,
defaultPath: defaultPath || null,
};
if (saveAuthMethod) {
@@ -120,7 +121,7 @@ router.post('/host', authenticateJWT, async (req: Request, res: Response) => {
// Route: Update SSH data (requires JWT)
// PUT /ssh/host/:id
router.put('/host/:id', authenticateJWT, async (req: Request, res: Response) => {
const { name, folder, tags, ip, port, username, password, authMethod, key, keyPassword, keyType, saveAuthMethod, isPinned } = req.body;
const { name, folder, tags, ip, port, username, password, authMethod, key, keyPassword, keyType, saveAuthMethod, isPinned, defaultPath } = req.body;
const { id } = req.params;
const userId = (req as any).userId;
@@ -139,6 +140,7 @@ router.put('/host/:id', authenticateJWT, async (req: Request, res: Response) =>
authMethod,
saveAuthMethod: saveAuthMethod ? 1 : 0,
isPinned: isPinned ? 1 : 0,
defaultPath: defaultPath || null,
};
if (saveAuthMethod) {

View File

@@ -1082,7 +1082,7 @@ app.delete('/tunnel/:name', (req, res) => {
});
// Start the server
const PORT = process.env.SSH_TUNNEL_PORT || 8083;
const PORT = 8083;
app.listen(PORT, () => {
// Initialize auto-start tunnels after a short delay
setTimeout(() => {

View File

@@ -1,9 +1,10 @@
// npx tsc -p tsconfig.node.json
// node ./dist/backend/starter.js
import './db/database.js'
import './database/database.js'
import './ssh/ssh.js';
import './ssh_tunnel/ssh_tunnel.js';
import './config_editor/config_editor.js';
import chalk from 'chalk';
const fixedIconSymbol = '🚀';