feat: implement comprehensive SSH credentials management system
- Add complete SSH credentials CRUD operations with AES-256 encryption - Implement database migration system for schema versioning - Create modern UI with Zinc theme for credentials management - Add credential viewer and editor with responsive design - Support password and SSH key authentication methods - Include usage tracking and folder organization - Enhance sidebar width and improve page spacing - Add comprehensive i18n support (EN/ZH) - Integrate with existing SSH host management 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -3,9 +3,12 @@ import bodyParser from 'body-parser';
|
||||
import userRoutes from './routes/users.js';
|
||||
import sshRoutes from './routes/ssh.js';
|
||||
import alertRoutes from './routes/alerts.js';
|
||||
import credentialsRoutes from './routes/credentials.js';
|
||||
import chalk from 'chalk';
|
||||
import cors from 'cors';
|
||||
import fetch from 'node-fetch';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import 'dotenv/config';
|
||||
|
||||
const app = express();
|
||||
@@ -143,9 +146,26 @@ app.get('/health', (req, res) => {
|
||||
});
|
||||
|
||||
app.get('/version', async (req, res) => {
|
||||
const localVersion = process.env.VERSION;
|
||||
let localVersion = process.env.VERSION;
|
||||
|
||||
// Fallback to package.json version if env variable not set
|
||||
if (!localVersion) {
|
||||
try {
|
||||
const packagePath = path.resolve(process.cwd(), 'package.json');
|
||||
const packageJson = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
|
||||
localVersion = packageJson.version;
|
||||
logger.info(`Using version from package.json: ${localVersion}`);
|
||||
} catch (error) {
|
||||
logger.error('Failed to read version from package.json:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Debug logging
|
||||
logger.debug(`Final version: ${localVersion}`);
|
||||
logger.debug(`Working directory: ${process.cwd()}`);
|
||||
|
||||
if (!localVersion) {
|
||||
logger.error('No version information available');
|
||||
return res.status(404).send('Local Version Not Set');
|
||||
}
|
||||
|
||||
@@ -235,19 +255,11 @@ app.get('/releases/rss', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Health check endpoint for Electron backend manager
|
||||
app.get('/health', (req, res) => {
|
||||
res.status(200).json({
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
service: 'database-api',
|
||||
port: PORT
|
||||
});
|
||||
});
|
||||
|
||||
app.use('/users', userRoutes);
|
||||
app.use('/ssh', sshRoutes);
|
||||
app.use('/alerts', alertRoutes);
|
||||
app.use('/credentials', credentialsRoutes);
|
||||
|
||||
app.use((err: unknown, req: express.Request, res: express.Response, next: express.NextFunction) => {
|
||||
logger.error('Unhandled error:', err);
|
||||
|
||||
@@ -4,6 +4,7 @@ import * as schema from './schema.js';
|
||||
import chalk from 'chalk';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { MigrationManager } from '../migrations/migrator.js';
|
||||
|
||||
const dbIconSymbol = '🗄️';
|
||||
const getTimeStamp = (): string => chalk.gray(`[${new Date().toLocaleTimeString()}]`);
|
||||
@@ -432,6 +433,9 @@ const migrateSchema = () => {
|
||||
addColumnIfNotExists('ssh_data', 'default_path', 'TEXT');
|
||||
addColumnIfNotExists('ssh_data', 'created_at', 'TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP');
|
||||
addColumnIfNotExists('ssh_data', 'updated_at', 'TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP');
|
||||
|
||||
// Add credential_id column for SSH credentials management
|
||||
addColumnIfNotExists('ssh_data', 'credential_id', 'INTEGER REFERENCES ssh_credentials(id)');
|
||||
|
||||
addColumnIfNotExists('file_manager_recent', 'host_id', 'INTEGER NOT NULL');
|
||||
addColumnIfNotExists('file_manager_pinned', 'host_id', 'INTEGER NOT NULL');
|
||||
@@ -440,15 +444,27 @@ const migrateSchema = () => {
|
||||
logger.success('Schema migration completed');
|
||||
};
|
||||
|
||||
migrateSchema();
|
||||
const initializeDatabase = async () => {
|
||||
migrateSchema();
|
||||
|
||||
try {
|
||||
const row = sqlite.prepare("SELECT value FROM settings WHERE key = 'allow_registration'").get();
|
||||
if (!row) {
|
||||
sqlite.prepare("INSERT INTO settings (key, value) VALUES ('allow_registration', 'true')").run();
|
||||
// Run new migration system
|
||||
const migrationManager = new MigrationManager(sqlite);
|
||||
await migrationManager.runMigrations();
|
||||
|
||||
try {
|
||||
const row = sqlite.prepare("SELECT value FROM settings WHERE key = 'allow_registration'").get();
|
||||
if (!row) {
|
||||
sqlite.prepare("INSERT INTO settings (key, value) VALUES ('allow_registration', 'true')").run();
|
||||
}
|
||||
} catch (e) {
|
||||
logger.warn('Could not initialize default settings');
|
||||
}
|
||||
} catch (e) {
|
||||
logger.warn('Could not initialize default settings');
|
||||
}
|
||||
};
|
||||
|
||||
// Initialize database (async)
|
||||
initializeDatabase().catch(error => {
|
||||
logger.error('Failed to initialize database:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
export const db = drizzle(sqlite, {schema});
|
||||
@@ -39,10 +39,13 @@ export const sshData = sqliteTable('ssh_data', {
|
||||
tags: text('tags'),
|
||||
pin: integer('pin', {mode: 'boolean'}).notNull().default(false),
|
||||
authType: text('auth_type').notNull(),
|
||||
// Legacy credential fields - kept for backward compatibility
|
||||
password: text('password'),
|
||||
key: text('key', {length: 8192}),
|
||||
keyPassword: text('key_password'),
|
||||
keyType: text('key_type'),
|
||||
// New credential management
|
||||
credentialId: integer('credential_id').references(() => sshCredentials.id),
|
||||
enableTerminal: integer('enable_terminal', {mode: 'boolean'}).notNull().default(true),
|
||||
enableTunnel: integer('enable_tunnel', {mode: 'boolean'}).notNull().default(true),
|
||||
tunnelConnections: text('tunnel_connections'),
|
||||
@@ -84,4 +87,32 @@ export const dismissedAlerts = sqliteTable('dismissed_alerts', {
|
||||
userId: text('user_id').notNull().references(() => users.id),
|
||||
alertId: text('alert_id').notNull(),
|
||||
dismissedAt: text('dismissed_at').notNull().default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
|
||||
// SSH Credentials Management Tables
|
||||
export const sshCredentials = sqliteTable('ssh_credentials', {
|
||||
id: integer('id').primaryKey({autoIncrement: true}),
|
||||
userId: text('user_id').notNull().references(() => users.id),
|
||||
name: text('name').notNull(),
|
||||
description: text('description'),
|
||||
folder: text('folder'),
|
||||
tags: text('tags'),
|
||||
authType: text('auth_type').notNull(), // 'password' | 'key'
|
||||
username: text('username').notNull(),
|
||||
encryptedPassword: text('encrypted_password'), // AES encrypted
|
||||
encryptedKey: text('encrypted_key', {length: 16384}), // AES encrypted SSH key
|
||||
encryptedKeyPassword: text('encrypted_key_password'), // AES encrypted key passphrase
|
||||
keyType: text('key_type'), // 'rsa' | 'ecdsa' | 'ed25519'
|
||||
usageCount: integer('usage_count').notNull().default(0),
|
||||
lastUsed: text('last_used'),
|
||||
createdAt: text('created_at').notNull().default(sql`CURRENT_TIMESTAMP`),
|
||||
updatedAt: text('updated_at').notNull().default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
|
||||
export const sshCredentialUsage = sqliteTable('ssh_credential_usage', {
|
||||
id: integer('id').primaryKey({autoIncrement: true}),
|
||||
credentialId: integer('credential_id').notNull().references(() => sshCredentials.id),
|
||||
hostId: integer('host_id').notNull().references(() => sshData.id),
|
||||
userId: text('user_id').notNull().references(() => users.id),
|
||||
usedAt: text('used_at').notNull().default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import type { Database } from 'better-sqlite3';
|
||||
|
||||
export const up = (db: Database) => {
|
||||
// Create SSH credentials table
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS ssh_credentials (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL REFERENCES users(id),
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
folder TEXT,
|
||||
tags TEXT,
|
||||
auth_type TEXT NOT NULL,
|
||||
username TEXT NOT NULL,
|
||||
encrypted_password TEXT,
|
||||
encrypted_key TEXT,
|
||||
encrypted_key_password TEXT,
|
||||
key_type TEXT,
|
||||
usage_count INTEGER NOT NULL DEFAULT 0,
|
||||
last_used TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`);
|
||||
|
||||
// Create credential usage tracking table
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS ssh_credential_usage (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
credential_id INTEGER NOT NULL REFERENCES ssh_credentials(id),
|
||||
host_id INTEGER NOT NULL REFERENCES ssh_data(id),
|
||||
user_id TEXT NOT NULL REFERENCES users(id),
|
||||
used_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`);
|
||||
|
||||
// Add credential_id column to ssh_data table if it doesn't exist
|
||||
const columns = db.prepare(`PRAGMA table_info(ssh_data)`).all();
|
||||
const hasCredentialId = columns.some((col: any) => col.name === 'credential_id');
|
||||
|
||||
if (!hasCredentialId) {
|
||||
db.exec(`
|
||||
ALTER TABLE ssh_data
|
||||
ADD COLUMN credential_id INTEGER REFERENCES ssh_credentials(id)
|
||||
`);
|
||||
}
|
||||
|
||||
// Create indexes for better performance
|
||||
db.exec(`CREATE INDEX IF NOT EXISTS idx_ssh_credentials_user_id ON ssh_credentials(user_id)`);
|
||||
db.exec(`CREATE INDEX IF NOT EXISTS idx_ssh_credentials_folder ON ssh_credentials(folder)`);
|
||||
db.exec(`CREATE INDEX IF NOT EXISTS idx_ssh_credential_usage_credential_id ON ssh_credential_usage(credential_id)`);
|
||||
db.exec(`CREATE INDEX IF NOT EXISTS idx_ssh_credential_usage_host_id ON ssh_credential_usage(host_id)`);
|
||||
db.exec(`CREATE INDEX IF NOT EXISTS idx_ssh_data_credential_id ON ssh_data(credential_id)`);
|
||||
|
||||
console.log('✅ Added SSH credentials management tables');
|
||||
};
|
||||
|
||||
export const down = (db: Database) => {
|
||||
// Remove credential_id column from ssh_data table
|
||||
db.exec(`
|
||||
CREATE TABLE ssh_data_backup AS SELECT
|
||||
id, user_id, name, ip, port, username, folder, tags, pin, auth_type,
|
||||
password, key, key_password, key_type, enable_terminal, enable_tunnel,
|
||||
tunnel_connections, enable_file_manager, default_path, created_at, updated_at
|
||||
FROM ssh_data
|
||||
`);
|
||||
|
||||
db.exec(`DROP TABLE ssh_data`);
|
||||
db.exec(`ALTER TABLE ssh_data_backup RENAME TO ssh_data`);
|
||||
|
||||
// Drop credential tables
|
||||
db.exec(`DROP TABLE IF EXISTS ssh_credential_usage`);
|
||||
db.exec(`DROP TABLE IF EXISTS ssh_credentials`);
|
||||
|
||||
console.log('✅ Removed SSH credentials management tables');
|
||||
};
|
||||
@@ -0,0 +1,261 @@
|
||||
import type { Database } from 'better-sqlite3';
|
||||
import chalk from 'chalk';
|
||||
import { readFileSync, readdirSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { dirname } from 'path';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
const logger = {
|
||||
info: (msg: string): void => {
|
||||
const timestamp = chalk.gray(`[${new Date().toLocaleTimeString()}]`);
|
||||
console.log(`${timestamp} ${chalk.cyan('[MIGRATION]')} ${msg}`);
|
||||
},
|
||||
warn: (msg: string): void => {
|
||||
const timestamp = chalk.gray(`[${new Date().toLocaleTimeString()}]`);
|
||||
console.warn(`${timestamp} ${chalk.yellow('[MIGRATION]')} ${msg}`);
|
||||
},
|
||||
error: (msg: string, err?: unknown): void => {
|
||||
const timestamp = chalk.gray(`[${new Date().toLocaleTimeString()}]`);
|
||||
console.error(`${timestamp} ${chalk.redBright('[MIGRATION]')} ${msg}`);
|
||||
if (err) console.error(err);
|
||||
},
|
||||
success: (msg: string): void => {
|
||||
const timestamp = chalk.gray(`[${new Date().toLocaleTimeString()}]`);
|
||||
console.log(`${timestamp} ${chalk.greenBright('[MIGRATION]')} ${msg}`);
|
||||
}
|
||||
};
|
||||
|
||||
interface Migration {
|
||||
id: string;
|
||||
name: string;
|
||||
up: (db: Database) => void;
|
||||
down: (db: Database) => void;
|
||||
}
|
||||
|
||||
class MigrationManager {
|
||||
private db: Database;
|
||||
private migrationsPath: string;
|
||||
|
||||
constructor(db: Database) {
|
||||
this.db = db;
|
||||
this.migrationsPath = __dirname;
|
||||
this.ensureMigrationsTable();
|
||||
}
|
||||
|
||||
private ensureMigrationsTable() {
|
||||
this.db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS migrations (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
applied_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`);
|
||||
}
|
||||
|
||||
private getAppliedMigrations(): Set<string> {
|
||||
const applied = this.db.prepare('SELECT id FROM migrations').all() as { id: string }[];
|
||||
return new Set(applied.map(m => m.id));
|
||||
}
|
||||
|
||||
private async loadMigration(filename: string): Promise<Migration | null> {
|
||||
try {
|
||||
const migrationPath = join(this.migrationsPath, filename);
|
||||
// Convert to file:// URL for Windows compatibility
|
||||
const migrationUrl = process.platform === 'win32'
|
||||
? `file:///${migrationPath.replace(/\\/g, '/')}`
|
||||
: migrationPath;
|
||||
const migration = await import(migrationUrl);
|
||||
|
||||
// Extract migration ID and name from filename
|
||||
const matches = filename.match(/^(\d+)-(.+)\.(ts|js)$/);
|
||||
if (!matches) {
|
||||
logger.warn(`Skipping invalid migration filename: ${filename}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const [, id, name] = matches;
|
||||
|
||||
return {
|
||||
id: id.padStart(3, '0'),
|
||||
name: name.replace(/-/g, ' '),
|
||||
up: migration.up,
|
||||
down: migration.down
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error(`Failed to load migration ${filename}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private getMigrationFiles(): string[] {
|
||||
try {
|
||||
return readdirSync(this.migrationsPath)
|
||||
.filter(file => (file.endsWith('.ts') || file.endsWith('.js')) && !file.includes('migrator'))
|
||||
.sort();
|
||||
} catch (error) {
|
||||
logger.error('Failed to read migrations directory:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async runMigrations(): Promise<void> {
|
||||
logger.info('Starting database migrations...');
|
||||
|
||||
const migrationFiles = this.getMigrationFiles();
|
||||
if (migrationFiles.length === 0) {
|
||||
logger.info('No migrations found');
|
||||
return;
|
||||
}
|
||||
|
||||
const appliedMigrations = this.getAppliedMigrations();
|
||||
const migrations: Migration[] = [];
|
||||
|
||||
// Load all migrations
|
||||
for (const filename of migrationFiles) {
|
||||
const migration = await this.loadMigration(filename);
|
||||
if (migration) {
|
||||
migrations.push(migration);
|
||||
}
|
||||
}
|
||||
|
||||
// Filter out already applied migrations
|
||||
const pendingMigrations = migrations.filter(m => !appliedMigrations.has(m.id));
|
||||
|
||||
if (pendingMigrations.length === 0) {
|
||||
logger.info('All migrations are already applied');
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(`Found ${pendingMigrations.length} pending migration(s)`);
|
||||
|
||||
// Run pending migrations in transaction
|
||||
const transaction = this.db.transaction(() => {
|
||||
for (const migration of pendingMigrations) {
|
||||
logger.info(`Applying migration ${migration.id}: ${migration.name}`);
|
||||
|
||||
try {
|
||||
migration.up(this.db);
|
||||
|
||||
// Record the migration
|
||||
this.db.prepare(`
|
||||
INSERT INTO migrations (id, name)
|
||||
VALUES (?, ?)
|
||||
`).run(migration.id, migration.name);
|
||||
|
||||
logger.success(`Applied migration ${migration.id}: ${migration.name}`);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to apply migration ${migration.id}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
transaction();
|
||||
logger.success(`Successfully applied ${pendingMigrations.length} migration(s)`);
|
||||
} catch (error) {
|
||||
logger.error('Migration transaction failed, rolling back:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async rollbackMigration(targetId?: string): Promise<void> {
|
||||
logger.warn('Starting migration rollback...');
|
||||
|
||||
const appliedMigrations = this.db.prepare(`
|
||||
SELECT id, name FROM migrations
|
||||
ORDER BY id DESC
|
||||
`).all() as { id: string; name: string }[];
|
||||
|
||||
if (appliedMigrations.length === 0) {
|
||||
logger.info('No migrations to rollback');
|
||||
return;
|
||||
}
|
||||
|
||||
const migrationsToRollback = targetId
|
||||
? appliedMigrations.filter(m => m.id >= targetId)
|
||||
: [appliedMigrations[0]]; // Only rollback the latest
|
||||
|
||||
const migrationFiles = this.getMigrationFiles();
|
||||
const migrations: Migration[] = [];
|
||||
|
||||
// Load migrations that need to be rolled back
|
||||
for (const filename of migrationFiles) {
|
||||
const migration = await this.loadMigration(filename);
|
||||
if (migration && migrationsToRollback.some(m => m.id === migration.id)) {
|
||||
migrations.push(migration);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort in reverse order for rollback
|
||||
migrations.sort((a, b) => b.id.localeCompare(a.id));
|
||||
|
||||
const transaction = this.db.transaction(() => {
|
||||
for (const migration of migrations) {
|
||||
logger.info(`Rolling back migration ${migration.id}: ${migration.name}`);
|
||||
|
||||
try {
|
||||
migration.down(this.db);
|
||||
|
||||
// Remove the migration record
|
||||
this.db.prepare(`DELETE FROM migrations WHERE id = ?`).run(migration.id);
|
||||
|
||||
logger.success(`Rolled back migration ${migration.id}: ${migration.name}`);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to rollback migration ${migration.id}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
transaction();
|
||||
logger.success(`Successfully rolled back ${migrations.length} migration(s)`);
|
||||
} catch (error) {
|
||||
logger.error('Rollback transaction failed:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
getMigrationStatus(): { id: string; name: string; applied: boolean }[] {
|
||||
const migrationFiles = this.getMigrationFiles();
|
||||
const appliedMigrations = this.getAppliedMigrations();
|
||||
|
||||
return migrationFiles.map(filename => {
|
||||
const matches = filename.match(/^(\d+)-(.+)\.(ts|js)$/);
|
||||
if (!matches) return null;
|
||||
|
||||
const [, id, name] = matches;
|
||||
const migrationId = id.padStart(3, '0');
|
||||
|
||||
return {
|
||||
id: migrationId,
|
||||
name: name.replace(/-/g, ' '),
|
||||
applied: appliedMigrations.has(migrationId)
|
||||
};
|
||||
}).filter(Boolean) as { id: string; name: string; applied: boolean }[];
|
||||
}
|
||||
|
||||
printStatus(): void {
|
||||
const status = this.getMigrationStatus();
|
||||
|
||||
logger.info('Migration Status:');
|
||||
console.log(chalk.gray('─'.repeat(60)));
|
||||
|
||||
status.forEach(migration => {
|
||||
const statusIcon = migration.applied ? chalk.green('✓') : chalk.yellow('○');
|
||||
const statusText = migration.applied ? chalk.green('Applied') : chalk.yellow('Pending');
|
||||
console.log(`${statusIcon} ${migration.id} - ${migration.name} [${statusText}]`);
|
||||
});
|
||||
|
||||
console.log(chalk.gray('─'.repeat(60)));
|
||||
const appliedCount = status.filter(m => m.applied).length;
|
||||
console.log(`Total: ${status.length} migrations, ${appliedCount} applied, ${status.length - appliedCount} pending`);
|
||||
}
|
||||
}
|
||||
|
||||
export { MigrationManager };
|
||||
export type { Migration };
|
||||
@@ -0,0 +1,270 @@
|
||||
import express from 'express';
|
||||
import {credentialService} from '../../services/credentials.js';
|
||||
import type {Request, Response, NextFunction} from 'express';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import chalk from 'chalk';
|
||||
|
||||
const credIconSymbol = '🔐';
|
||||
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('#0f766e')(`[${credIconSymbol}]`)} ${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));
|
||||
}
|
||||
};
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
interface JWTPayload {
|
||||
userId: string;
|
||||
iat?: number;
|
||||
exp?: number;
|
||||
}
|
||||
|
||||
function isNonEmptyString(val: any): val is string {
|
||||
return typeof val === 'string' && val.trim().length > 0;
|
||||
}
|
||||
|
||||
function authenticateJWT(req: Request, res: Response, next: NextFunction) {
|
||||
const authHeader = req.headers['authorization'];
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
logger.warn('Missing or invalid Authorization header');
|
||||
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) {
|
||||
logger.warn('Invalid or expired token');
|
||||
return res.status(401).json({error: 'Invalid or expired token'});
|
||||
}
|
||||
}
|
||||
|
||||
// Create a new credential
|
||||
// POST /credentials
|
||||
router.post('/', authenticateJWT, async (req: Request, res: Response) => {
|
||||
const userId = (req as any).userId;
|
||||
const {
|
||||
name,
|
||||
description,
|
||||
folder,
|
||||
tags,
|
||||
authType,
|
||||
username,
|
||||
password,
|
||||
key,
|
||||
keyPassword,
|
||||
keyType
|
||||
} = req.body;
|
||||
|
||||
if (!isNonEmptyString(userId) || !isNonEmptyString(name) || !isNonEmptyString(username)) {
|
||||
logger.warn('Invalid credential creation data');
|
||||
return res.status(400).json({error: 'Name and username are required'});
|
||||
}
|
||||
|
||||
if (!['password', 'key'].includes(authType)) {
|
||||
logger.warn('Invalid auth type');
|
||||
return res.status(400).json({error: 'Auth type must be "password" or "key"'});
|
||||
}
|
||||
|
||||
try {
|
||||
const credential = await credentialService.createCredential(userId, {
|
||||
name,
|
||||
description,
|
||||
folder,
|
||||
tags,
|
||||
authType,
|
||||
username,
|
||||
password,
|
||||
key,
|
||||
keyPassword,
|
||||
keyType
|
||||
});
|
||||
|
||||
logger.success(`Created credential: ${name}`);
|
||||
res.status(201).json(credential);
|
||||
} catch (err) {
|
||||
logger.error('Failed to create credential', err);
|
||||
res.status(500).json({
|
||||
error: err instanceof Error ? err.message : 'Failed to create credential'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Get all credentials for the authenticated user
|
||||
// GET /credentials
|
||||
router.get('/', authenticateJWT, async (req: Request, res: Response) => {
|
||||
const userId = (req as any).userId;
|
||||
|
||||
if (!isNonEmptyString(userId)) {
|
||||
logger.warn('Invalid userId for credential fetch');
|
||||
return res.status(400).json({error: 'Invalid userId'});
|
||||
}
|
||||
|
||||
try {
|
||||
const credentials = await credentialService.getUserCredentials(userId);
|
||||
res.json(credentials);
|
||||
} catch (err) {
|
||||
logger.error('Failed to fetch credentials', err);
|
||||
res.status(500).json({error: 'Failed to fetch credentials'});
|
||||
}
|
||||
});
|
||||
|
||||
// Get all unique credential folders for the authenticated user
|
||||
// GET /credentials/folders
|
||||
router.get('/folders', authenticateJWT, async (req: Request, res: Response) => {
|
||||
const userId = (req as any).userId;
|
||||
|
||||
if (!isNonEmptyString(userId)) {
|
||||
logger.warn('Invalid userId for credential folder fetch');
|
||||
return res.status(400).json({error: 'Invalid userId'});
|
||||
}
|
||||
|
||||
try {
|
||||
const folders = await credentialService.getCredentialsFolders(userId);
|
||||
res.json(folders);
|
||||
} catch (err) {
|
||||
logger.error('Failed to fetch credential folders', err);
|
||||
res.status(500).json({error: 'Failed to fetch credential folders'});
|
||||
}
|
||||
});
|
||||
|
||||
// Get a specific credential by ID (with decrypted secrets)
|
||||
// GET /credentials/:id
|
||||
router.get('/:id', authenticateJWT, async (req: Request, res: Response) => {
|
||||
const userId = (req as any).userId;
|
||||
const {id} = req.params;
|
||||
|
||||
if (!isNonEmptyString(userId) || !id) {
|
||||
logger.warn('Invalid request for credential fetch');
|
||||
return res.status(400).json({error: 'Invalid request'});
|
||||
}
|
||||
|
||||
try {
|
||||
const credential = await credentialService.getCredentialWithSecrets(userId, parseInt(id));
|
||||
|
||||
if (!credential) {
|
||||
return res.status(404).json({error: 'Credential not found'});
|
||||
}
|
||||
|
||||
res.json(credential);
|
||||
} catch (err) {
|
||||
logger.error('Failed to fetch credential', err);
|
||||
res.status(500).json({
|
||||
error: err instanceof Error ? err.message : 'Failed to fetch credential'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Update a credential
|
||||
// PUT /credentials/:id
|
||||
router.put('/:id', authenticateJWT, async (req: Request, res: Response) => {
|
||||
const userId = (req as any).userId;
|
||||
const {id} = req.params;
|
||||
const updateData = req.body;
|
||||
|
||||
if (!isNonEmptyString(userId) || !id) {
|
||||
logger.warn('Invalid request for credential update');
|
||||
return res.status(400).json({error: 'Invalid request'});
|
||||
}
|
||||
|
||||
try {
|
||||
const credential = await credentialService.updateCredential(userId, parseInt(id), updateData);
|
||||
logger.success(`Updated credential ID ${id}`);
|
||||
res.json(credential);
|
||||
} catch (err) {
|
||||
logger.error('Failed to update credential', err);
|
||||
res.status(500).json({
|
||||
error: err instanceof Error ? err.message : 'Failed to update credential'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Delete a credential
|
||||
// DELETE /credentials/:id
|
||||
router.delete('/:id', authenticateJWT, async (req: Request, res: Response) => {
|
||||
const userId = (req as any).userId;
|
||||
const {id} = req.params;
|
||||
|
||||
if (!isNonEmptyString(userId) || !id) {
|
||||
logger.warn('Invalid request for credential deletion');
|
||||
return res.status(400).json({error: 'Invalid request'});
|
||||
}
|
||||
|
||||
try {
|
||||
await credentialService.deleteCredential(userId, parseInt(id));
|
||||
logger.success(`Deleted credential ID ${id}`);
|
||||
res.json({message: 'Credential deleted successfully'});
|
||||
} catch (err) {
|
||||
logger.error('Failed to delete credential', err);
|
||||
res.status(500).json({
|
||||
error: err instanceof Error ? err.message : 'Failed to delete credential'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Apply a credential to an SSH host (for quick application)
|
||||
// POST /credentials/:id/apply-to-host/:hostId
|
||||
router.post('/:id/apply-to-host/:hostId', authenticateJWT, async (req: Request, res: Response) => {
|
||||
const userId = (req as any).userId;
|
||||
const {id: credentialId, hostId} = req.params;
|
||||
|
||||
if (!isNonEmptyString(userId) || !credentialId || !hostId) {
|
||||
logger.warn('Invalid request for credential application');
|
||||
return res.status(400).json({error: 'Invalid request'});
|
||||
}
|
||||
|
||||
try {
|
||||
const {sshHostService} = await import('../../services/ssh-host.js');
|
||||
await sshHostService.applyCredentialToHost(userId, parseInt(hostId), parseInt(credentialId));
|
||||
|
||||
logger.success(`Applied credential ${credentialId} to host ${hostId}`);
|
||||
res.json({message: 'Credential applied to host successfully'});
|
||||
} catch (err) {
|
||||
logger.error('Failed to apply credential to host', err);
|
||||
res.status(500).json({
|
||||
error: err instanceof Error ? err.message : 'Failed to apply credential to host'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Get hosts using a specific credential
|
||||
// GET /credentials/:id/hosts
|
||||
router.get('/:id/hosts', authenticateJWT, async (req: Request, res: Response) => {
|
||||
const userId = (req as any).userId;
|
||||
const {id: credentialId} = req.params;
|
||||
|
||||
if (!isNonEmptyString(userId) || !credentialId) {
|
||||
logger.warn('Invalid request for credential hosts fetch');
|
||||
return res.status(400).json({error: 'Invalid request'});
|
||||
}
|
||||
|
||||
try {
|
||||
const {sshHostService} = await import('../../services/ssh-host.js');
|
||||
const hosts = await sshHostService.getHostsUsingCredential(userId, parseInt(credentialId));
|
||||
|
||||
res.json(hosts);
|
||||
} catch (err) {
|
||||
logger.error('Failed to fetch hosts using credential', err);
|
||||
res.status(500).json({
|
||||
error: err instanceof Error ? err.message : 'Failed to fetch hosts using credential'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -691,6 +691,109 @@ router.delete('/file_manager/shortcuts', authenticateJWT, async (req: Request, r
|
||||
}
|
||||
});
|
||||
|
||||
// Route: Get SSH host by ID with resolved credentials (requires JWT)
|
||||
// GET /ssh/host/:id/with-credentials
|
||||
router.get('/db/host/:id/with-credentials', authenticateJWT, async (req: Request, res: Response) => {
|
||||
const {id} = req.params;
|
||||
const userId = (req as any).userId;
|
||||
|
||||
if (!isNonEmptyString(userId) || !id) {
|
||||
logger.warn('Invalid request for SSH host with credentials fetch');
|
||||
return res.status(400).json({error: 'Invalid request'});
|
||||
}
|
||||
|
||||
try {
|
||||
const {sshHostService} = await import('../../services/ssh-host.js');
|
||||
const host = await sshHostService.getHostWithCredentials(userId, parseInt(id));
|
||||
|
||||
if (!host) {
|
||||
return res.status(404).json({error: 'SSH host not found'});
|
||||
}
|
||||
|
||||
res.json(host);
|
||||
} catch (err) {
|
||||
logger.error('Failed to fetch SSH host with credentials', err);
|
||||
res.status(500).json({error: 'Failed to fetch SSH host with credentials'});
|
||||
}
|
||||
});
|
||||
|
||||
// Route: Apply credential to SSH host (requires JWT)
|
||||
// POST /ssh/host/:id/apply-credential
|
||||
router.post('/db/host/:id/apply-credential', authenticateJWT, async (req: Request, res: Response) => {
|
||||
const {id: hostId} = req.params;
|
||||
const {credentialId} = req.body;
|
||||
const userId = (req as any).userId;
|
||||
|
||||
if (!isNonEmptyString(userId) || !hostId || !credentialId) {
|
||||
logger.warn('Invalid request for applying credential to host');
|
||||
return res.status(400).json({error: 'Host ID and credential ID are required'});
|
||||
}
|
||||
|
||||
try {
|
||||
const {sshHostService} = await import('../../services/ssh-host.js');
|
||||
await sshHostService.applyCredentialToHost(userId, parseInt(hostId), parseInt(credentialId));
|
||||
|
||||
res.json({message: 'Credential applied to host successfully'});
|
||||
} catch (err) {
|
||||
logger.error('Failed to apply credential to host', err);
|
||||
res.status(500).json({
|
||||
error: err instanceof Error ? err.message : 'Failed to apply credential to host'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Route: Remove credential from SSH host (requires JWT)
|
||||
// DELETE /ssh/host/:id/credential
|
||||
router.delete('/db/host/:id/credential', authenticateJWT, async (req: Request, res: Response) => {
|
||||
const {id: hostId} = req.params;
|
||||
const userId = (req as any).userId;
|
||||
|
||||
if (!isNonEmptyString(userId) || !hostId) {
|
||||
logger.warn('Invalid request for removing credential from host');
|
||||
return res.status(400).json({error: 'Invalid request'});
|
||||
}
|
||||
|
||||
try {
|
||||
const {sshHostService} = await import('../../services/ssh-host.js');
|
||||
await sshHostService.removeCredentialFromHost(userId, parseInt(hostId));
|
||||
|
||||
res.json({message: 'Credential removed from host successfully'});
|
||||
} catch (err) {
|
||||
logger.error('Failed to remove credential from host', err);
|
||||
res.status(500).json({
|
||||
error: err instanceof Error ? err.message : 'Failed to remove credential from host'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Route: Migrate host to managed credential (requires JWT)
|
||||
// POST /ssh/host/:id/migrate-to-credential
|
||||
router.post('/db/host/:id/migrate-to-credential', authenticateJWT, async (req: Request, res: Response) => {
|
||||
const {id: hostId} = req.params;
|
||||
const {credentialName} = req.body;
|
||||
const userId = (req as any).userId;
|
||||
|
||||
if (!isNonEmptyString(userId) || !hostId || !credentialName) {
|
||||
logger.warn('Invalid request for migrating host to credential');
|
||||
return res.status(400).json({error: 'Host ID and credential name are required'});
|
||||
}
|
||||
|
||||
try {
|
||||
const {sshHostService} = await import('../../services/ssh-host.js');
|
||||
const credentialId = await sshHostService.migrateHostToCredential(userId, parseInt(hostId), credentialName);
|
||||
|
||||
res.json({
|
||||
message: 'Host migrated to managed credential successfully',
|
||||
credentialId
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error('Failed to migrate host to credential', err);
|
||||
res.status(500).json({
|
||||
error: err instanceof Error ? err.message : 'Failed to migrate host to credential'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Route: Bulk import SSH hosts from JSON (requires JWT)
|
||||
// POST /ssh/bulk-import
|
||||
router.post('/bulk-import', authenticateJWT, async (req: Request, res: Response) => {
|
||||
|
||||
Reference in New Issue
Block a user