Code cleanup
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -19,125 +19,51 @@ if (!fs.existsSync(dbDir)) {
|
||||
fs.mkdirSync(dbDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Database file encryption configuration
|
||||
const enableFileEncryption = process.env.DB_FILE_ENCRYPTION !== "false";
|
||||
const dbPath = path.join(dataDir, "db.sqlite");
|
||||
const encryptedDbPath = `${dbPath}.encrypted`;
|
||||
|
||||
// Initialize database with file encryption support
|
||||
let actualDbPath = ":memory:"; // Always use memory database
|
||||
let actualDbPath = ":memory:";
|
||||
let memoryDatabase: Database.Database;
|
||||
let isNewDatabase = false;
|
||||
let sqlite: Database.Database; // Module-level sqlite instance
|
||||
let sqlite: Database.Database;
|
||||
|
||||
// Async initialization function to handle SystemCrypto and DatabaseFileEncryption
|
||||
async function initializeDatabaseAsync(): Promise<void> {
|
||||
// Initialize SystemCrypto database key first
|
||||
databaseLogger.info("Initializing SystemCrypto database key...", {
|
||||
operation: "db_init_systemcrypto",
|
||||
envKeyAvailable: !!process.env.DATABASE_KEY,
|
||||
envKeyLength: process.env.DATABASE_KEY?.length || 0,
|
||||
});
|
||||
|
||||
const systemCrypto = SystemCrypto.getInstance();
|
||||
|
||||
// Verify key is available (should already be initialized by starter.ts)
|
||||
const dbKey = await systemCrypto.getDatabaseKey();
|
||||
databaseLogger.info("SystemCrypto database key verified", {
|
||||
operation: "db_init_systemcrypto_complete",
|
||||
keyLength: dbKey.length,
|
||||
keyAvailable: !!dbKey,
|
||||
});
|
||||
|
||||
const dbKey = await systemCrypto.getDatabaseKey();
|
||||
if (enableFileEncryption) {
|
||||
try {
|
||||
// Check if encrypted database exists
|
||||
if (DatabaseFileEncryption.isEncryptedDatabaseFile(encryptedDbPath)) {
|
||||
databaseLogger.info(
|
||||
"Found encrypted database file, loading into memory...",
|
||||
{
|
||||
operation: "db_memory_load",
|
||||
encryptedPath: encryptedDbPath,
|
||||
fileSize: fs.statSync(encryptedDbPath).size,
|
||||
},
|
||||
);
|
||||
|
||||
// Decrypt database content to memory buffer (now async)
|
||||
databaseLogger.info("Starting database decryption...", {
|
||||
operation: "db_decrypt_start",
|
||||
encryptedPath: encryptedDbPath,
|
||||
});
|
||||
|
||||
const decryptedBuffer =
|
||||
await DatabaseFileEncryption.decryptDatabaseToBuffer(encryptedDbPath);
|
||||
|
||||
databaseLogger.info("Database decryption successful", {
|
||||
operation: "db_decrypt_success",
|
||||
decryptedSize: decryptedBuffer.length,
|
||||
isSqlite: decryptedBuffer.slice(0, 16).toString().startsWith('SQLite format 3'),
|
||||
});
|
||||
|
||||
// Create in-memory database from decrypted buffer
|
||||
memoryDatabase = new Database(decryptedBuffer);
|
||||
|
||||
databaseLogger.info("In-memory database created from decrypted buffer", {
|
||||
operation: "db_memory_create_success",
|
||||
});
|
||||
} else {
|
||||
// No encrypted database exists - check if we need to migrate
|
||||
const migration = new DatabaseMigration(dataDir);
|
||||
const migrationStatus = migration.checkMigrationStatus();
|
||||
|
||||
databaseLogger.info("Migration status check completed", {
|
||||
operation: "migration_status",
|
||||
needsMigration: migrationStatus.needsMigration,
|
||||
hasUnencryptedDb: migrationStatus.hasUnencryptedDb,
|
||||
hasEncryptedDb: migrationStatus.hasEncryptedDb,
|
||||
unencryptedDbSize: migrationStatus.unencryptedDbSize,
|
||||
reason: migrationStatus.reason,
|
||||
});
|
||||
|
||||
if (migrationStatus.needsMigration) {
|
||||
// Perform automatic migration
|
||||
databaseLogger.info("Starting automatic database migration", {
|
||||
operation: "auto_migration_start",
|
||||
unencryptedDbSize: migrationStatus.unencryptedDbSize,
|
||||
});
|
||||
|
||||
const migrationResult = await migration.migrateDatabase();
|
||||
|
||||
if (migrationResult.success) {
|
||||
databaseLogger.success("Automatic database migration completed successfully", {
|
||||
operation: "auto_migration_success",
|
||||
migratedTables: migrationResult.migratedTables,
|
||||
migratedRows: migrationResult.migratedRows,
|
||||
duration: migrationResult.duration,
|
||||
backupPath: migrationResult.backupPath,
|
||||
});
|
||||
|
||||
// Clean up old backup files
|
||||
migration.cleanupOldBackups();
|
||||
|
||||
// Load the newly created encrypted database
|
||||
if (DatabaseFileEncryption.isEncryptedDatabaseFile(encryptedDbPath)) {
|
||||
databaseLogger.info("Loading migrated encrypted database into memory", {
|
||||
operation: "load_migrated_db",
|
||||
encryptedPath: encryptedDbPath,
|
||||
});
|
||||
|
||||
const decryptedBuffer = await DatabaseFileEncryption.decryptDatabaseToBuffer(encryptedDbPath);
|
||||
if (
|
||||
DatabaseFileEncryption.isEncryptedDatabaseFile(encryptedDbPath)
|
||||
) {
|
||||
const decryptedBuffer =
|
||||
await DatabaseFileEncryption.decryptDatabaseToBuffer(
|
||||
encryptedDbPath,
|
||||
);
|
||||
memoryDatabase = new Database(decryptedBuffer);
|
||||
isNewDatabase = false; // We have migrated data
|
||||
|
||||
databaseLogger.success("Migrated encrypted database loaded successfully", {
|
||||
operation: "load_migrated_db_success",
|
||||
decryptedSize: decryptedBuffer.length,
|
||||
});
|
||||
isNewDatabase = false;
|
||||
} else {
|
||||
throw new Error("Migration completed but encrypted database file not found");
|
||||
throw new Error(
|
||||
"Migration completed but encrypted database file not found",
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Migration failed - this is critical
|
||||
databaseLogger.error("Automatic database migration failed", null, {
|
||||
operation: "auto_migration_failed",
|
||||
error: migrationResult.error,
|
||||
@@ -146,24 +72,13 @@ async function initializeDatabaseAsync(): Promise<void> {
|
||||
duration: migrationResult.duration,
|
||||
backupPath: migrationResult.backupPath,
|
||||
});
|
||||
|
||||
// CRITICAL: Migration failure with existing data
|
||||
console.error("DATABASE MIGRATION FAILED - THIS IS CRITICAL!");
|
||||
console.error("Migration error:", migrationResult.error);
|
||||
console.error("Backup available at:", migrationResult.backupPath);
|
||||
console.error("Manual intervention required to recover data.");
|
||||
|
||||
throw new Error(`Database migration failed: ${migrationResult.error}. Backup available at: ${migrationResult.backupPath}`);
|
||||
throw new Error(
|
||||
`Database migration failed: ${migrationResult.error}. Backup available at: ${migrationResult.backupPath}`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// No migration needed - create fresh database
|
||||
memoryDatabase = new Database(":memory:");
|
||||
isNewDatabase = true;
|
||||
|
||||
databaseLogger.info("Creating fresh in-memory database", {
|
||||
operation: "fresh_db_create",
|
||||
reason: migrationStatus.reason,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -171,20 +86,15 @@ async function initializeDatabaseAsync(): Promise<void> {
|
||||
operation: "db_memory_init_failed",
|
||||
errorMessage: error instanceof Error ? error.message : "Unknown error",
|
||||
errorStack: error instanceof Error ? error.stack : undefined,
|
||||
encryptedDbExists: DatabaseFileEncryption.isEncryptedDatabaseFile(encryptedDbPath),
|
||||
encryptedDbExists:
|
||||
DatabaseFileEncryption.isEncryptedDatabaseFile(encryptedDbPath),
|
||||
databaseKeyAvailable: !!process.env.DATABASE_KEY,
|
||||
databaseKeyLength: process.env.DATABASE_KEY?.length || 0,
|
||||
});
|
||||
|
||||
// CRITICAL: Never silently ignore database decryption failures!
|
||||
// This causes complete data loss for users
|
||||
console.error("DATABASE DECRYPTION FAILED - THIS IS CRITICAL!");
|
||||
console.error("Error details:", error instanceof Error ? error.message : error);
|
||||
console.error("Encrypted file exists:", DatabaseFileEncryption.isEncryptedDatabaseFile(encryptedDbPath));
|
||||
console.error("DATABASE_KEY available:", !!process.env.DATABASE_KEY);
|
||||
|
||||
// Always fail fast on decryption errors - data integrity is critical
|
||||
throw new Error(`Database decryption failed: ${error instanceof Error ? error.message : "Unknown error"}. This prevents data loss.`);
|
||||
throw new Error(
|
||||
`Database decryption failed: ${error instanceof Error ? error.message : "Unknown error"}. This prevents data loss.`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
memoryDatabase = new Database(":memory:");
|
||||
@@ -192,9 +102,7 @@ async function initializeDatabaseAsync(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
// Main async initialization function that combines database setup with schema creation
|
||||
async function initializeCompleteDatabase(): Promise<void> {
|
||||
// First initialize the database and SystemCrypto
|
||||
await initializeDatabaseAsync();
|
||||
|
||||
databaseLogger.info(`Initializing SQLite database`, {
|
||||
@@ -207,17 +115,10 @@ async function initializeCompleteDatabase(): Promise<void> {
|
||||
isNewDatabase,
|
||||
});
|
||||
|
||||
// Create module-level sqlite instance after database is initialized
|
||||
sqlite = memoryDatabase;
|
||||
|
||||
// Initialize drizzle ORM with the configured database
|
||||
db = drizzle(sqlite, { schema });
|
||||
|
||||
databaseLogger.info("Database ORM initialized", {
|
||||
operation: "drizzle_init",
|
||||
tablesConfigured: Object.keys(schema).length
|
||||
});
|
||||
|
||||
sqlite.exec(`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY,
|
||||
@@ -340,19 +241,13 @@ async function initializeCompleteDatabase(): Promise<void> {
|
||||
|
||||
`);
|
||||
|
||||
// Run schema migrations
|
||||
migrateSchema();
|
||||
|
||||
// Initialize default settings
|
||||
try {
|
||||
const row = sqlite
|
||||
.prepare("SELECT value FROM settings WHERE key = 'allow_registration'")
|
||||
.get();
|
||||
if (!row) {
|
||||
databaseLogger.info("Initializing default settings", {
|
||||
operation: "db_init",
|
||||
setting: "allow_registration",
|
||||
});
|
||||
sqlite
|
||||
.prepare(
|
||||
"INSERT INTO settings (key, value) VALUES ('allow_registration', 'true')",
|
||||
@@ -383,11 +278,6 @@ const addColumnIfNotExists = (
|
||||
try {
|
||||
sqlite.exec(`ALTER TABLE ${table}
|
||||
ADD COLUMN ${column} ${definition};`);
|
||||
databaseLogger.success(`Column ${column} added to ${table}`, {
|
||||
operation: "schema_migration",
|
||||
table,
|
||||
column,
|
||||
});
|
||||
} catch (alterError) {
|
||||
databaseLogger.warn(`Failed to add column ${column} to ${table}`, {
|
||||
operation: "schema_migration",
|
||||
@@ -400,10 +290,6 @@ const addColumnIfNotExists = (
|
||||
};
|
||||
|
||||
const migrateSchema = () => {
|
||||
databaseLogger.info("Checking for schema updates...", {
|
||||
operation: "schema_migration",
|
||||
});
|
||||
|
||||
addColumnIfNotExists("users", "is_admin", "INTEGER NOT NULL DEFAULT 0");
|
||||
|
||||
addColumnIfNotExists("users", "is_oidc", "INTEGER NOT NULL DEFAULT 0");
|
||||
@@ -469,13 +355,10 @@ const migrateSchema = () => {
|
||||
"INTEGER REFERENCES ssh_credentials(id)",
|
||||
);
|
||||
|
||||
// AutoStart plaintext columns
|
||||
addColumnIfNotExists("ssh_data", "autostart_password", "TEXT");
|
||||
addColumnIfNotExists("ssh_data", "autostart_key", "TEXT");
|
||||
addColumnIfNotExists("ssh_data", "autostart_key_password", "TEXT");
|
||||
|
||||
|
||||
// SSH credentials table migrations for encryption support
|
||||
addColumnIfNotExists("ssh_credentials", "private_key", "TEXT");
|
||||
addColumnIfNotExists("ssh_credentials", "public_key", "TEXT");
|
||||
addColumnIfNotExists("ssh_credentials", "detected_key_type", "TEXT");
|
||||
@@ -489,28 +372,22 @@ const migrateSchema = () => {
|
||||
});
|
||||
};
|
||||
|
||||
// Function to save in-memory database to file (encrypted or unencrypted fallback)
|
||||
async function saveMemoryDatabaseToFile() {
|
||||
if (!memoryDatabase) return;
|
||||
|
||||
try {
|
||||
// Export in-memory database to buffer
|
||||
const buffer = memoryDatabase.serialize();
|
||||
|
||||
// Ensure data directory exists
|
||||
if (!fs.existsSync(dataDir)) {
|
||||
fs.mkdirSync(dataDir, { recursive: true });
|
||||
databaseLogger.info("Created data directory", {
|
||||
operation: "data_dir_create",
|
||||
path: dataDir,
|
||||
});
|
||||
}
|
||||
|
||||
if (enableFileEncryption) {
|
||||
// Save as encrypted file
|
||||
await DatabaseFileEncryption.encryptDatabaseFromBuffer(buffer, encryptedDbPath);
|
||||
await DatabaseFileEncryption.encryptDatabaseFromBuffer(
|
||||
buffer,
|
||||
encryptedDbPath,
|
||||
);
|
||||
} else {
|
||||
// Fallback: save as unencrypted SQLite file to prevent data loss
|
||||
fs.writeFileSync(dbPath, buffer);
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -521,55 +398,30 @@ async function saveMemoryDatabaseToFile() {
|
||||
}
|
||||
}
|
||||
|
||||
// Function to handle post-initialization file encryption and periodic saves
|
||||
async function handlePostInitFileEncryption() {
|
||||
if (!enableFileEncryption) return;
|
||||
|
||||
try {
|
||||
// Check for any remaining unencrypted database files that may need attention
|
||||
if (fs.existsSync(dbPath)) {
|
||||
// This could happen if migration was skipped or if there are multiple database files
|
||||
databaseLogger.warn(
|
||||
"Unencrypted database file still exists after initialization",
|
||||
{
|
||||
operation: "db_security_check",
|
||||
path: dbPath,
|
||||
note: "This may be normal if migration was skipped for safety reasons",
|
||||
},
|
||||
);
|
||||
|
||||
// Don't automatically delete - let migration logic handle this
|
||||
// This provides better safety and transparency
|
||||
}
|
||||
|
||||
// Always save the in-memory database (whether new or existing)
|
||||
if (memoryDatabase) {
|
||||
// Save immediately after initialization
|
||||
await saveMemoryDatabaseToFile();
|
||||
|
||||
databaseLogger.info("Setting up periodic database saves", {
|
||||
operation: "db_periodic_save_setup",
|
||||
interval: "15 seconds",
|
||||
});
|
||||
|
||||
// Set up periodic saves every 15 seconds for real-time persistence
|
||||
setInterval(saveMemoryDatabaseToFile, 15 * 1000);
|
||||
|
||||
// Initialize database save trigger for real-time saves
|
||||
DatabaseSaveTrigger.initialize(saveMemoryDatabaseToFile);
|
||||
}
|
||||
|
||||
// Perform migration cleanup on startup (remove old backup files)
|
||||
try {
|
||||
const migration = new DatabaseMigration(dataDir);
|
||||
migration.cleanupOldBackups();
|
||||
} catch (cleanupError) {
|
||||
databaseLogger.warn("Failed to cleanup old migration files", {
|
||||
operation: "migration_cleanup_startup_failed",
|
||||
error: cleanupError instanceof Error ? cleanupError.message : "Unknown error",
|
||||
error:
|
||||
cleanupError instanceof Error
|
||||
? cleanupError.message
|
||||
: "Unknown error",
|
||||
});
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
databaseLogger.error(
|
||||
"Failed to handle database file encryption setup",
|
||||
@@ -578,31 +430,17 @@ async function handlePostInitFileEncryption() {
|
||||
operation: "db_encrypt_setup_failed",
|
||||
},
|
||||
);
|
||||
|
||||
// Don't fail the entire initialization for this
|
||||
}
|
||||
}
|
||||
|
||||
// Database initialization function - called explicitly, not at module import time
|
||||
async function initializeDatabase(): Promise<void> {
|
||||
await initializeCompleteDatabase();
|
||||
await handlePostInitFileEncryption();
|
||||
|
||||
databaseLogger.success("Database connection established", {
|
||||
operation: "db_init",
|
||||
path: actualDbPath,
|
||||
hasEncryptedBackup:
|
||||
enableFileEncryption &&
|
||||
DatabaseFileEncryption.isEncryptedDatabaseFile(encryptedDbPath),
|
||||
});
|
||||
}
|
||||
|
||||
// Export the initialization function instead of auto-starting
|
||||
export { initializeDatabase };
|
||||
|
||||
// Cleanup function for database and temporary files
|
||||
async function cleanupDatabase() {
|
||||
// Save in-memory database before closing
|
||||
if (memoryDatabase) {
|
||||
try {
|
||||
await saveMemoryDatabaseToFile();
|
||||
@@ -617,7 +455,6 @@ async function cleanupDatabase() {
|
||||
}
|
||||
}
|
||||
|
||||
// Close database connection
|
||||
try {
|
||||
if (sqlite) {
|
||||
sqlite.close();
|
||||
@@ -629,7 +466,6 @@ async function cleanupDatabase() {
|
||||
});
|
||||
}
|
||||
|
||||
// Clean up temp directory
|
||||
try {
|
||||
const tempDir = path.join(dataDir, ".temp");
|
||||
if (fs.existsSync(tempDir)) {
|
||||
@@ -637,25 +473,17 @@ async function cleanupDatabase() {
|
||||
for (const file of files) {
|
||||
try {
|
||||
fs.unlinkSync(path.join(tempDir, file));
|
||||
} catch {
|
||||
// Ignore individual file cleanup errors
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
try {
|
||||
fs.rmdirSync(tempDir);
|
||||
} catch {
|
||||
// Ignore directory removal errors
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
} catch (error) {
|
||||
// Ignore temp directory cleanup errors
|
||||
}
|
||||
} catch (error) {}
|
||||
}
|
||||
|
||||
// Register cleanup handlers
|
||||
process.on("exit", () => {
|
||||
// Synchronous cleanup only for exit event
|
||||
if (sqlite) {
|
||||
try {
|
||||
sqlite.close();
|
||||
@@ -679,26 +507,26 @@ process.on("SIGTERM", async () => {
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
// Database connection - will be initialized after database setup
|
||||
let db: ReturnType<typeof drizzle<typeof schema>>;
|
||||
|
||||
// Export database connection getter function to avoid undefined access
|
||||
export function getDb(): ReturnType<typeof drizzle<typeof schema>> {
|
||||
if (!db) {
|
||||
throw new Error("Database not initialized. Ensure initializeDatabase() is called before accessing db.");
|
||||
throw new Error(
|
||||
"Database not initialized. Ensure initializeDatabase() is called before accessing db.",
|
||||
);
|
||||
}
|
||||
return db;
|
||||
}
|
||||
|
||||
// Export raw SQLite instance for migrations
|
||||
export function getSqlite(): Database.Database {
|
||||
if (!sqlite) {
|
||||
throw new Error("SQLite not initialized. Ensure initializeDatabase() is called before accessing sqlite.");
|
||||
throw new Error(
|
||||
"SQLite not initialized. Ensure initializeDatabase() is called before accessing sqlite.",
|
||||
);
|
||||
}
|
||||
return sqlite;
|
||||
}
|
||||
|
||||
// Legacy export for compatibility - will throw if accessed before initialization
|
||||
export { db };
|
||||
export { DatabaseFileEncryption };
|
||||
export const databasePaths = {
|
||||
@@ -708,30 +536,6 @@ export const databasePaths = {
|
||||
inMemory: true,
|
||||
};
|
||||
|
||||
// Memory database buffer function
|
||||
function getMemoryDatabaseBuffer(): Buffer {
|
||||
if (!memoryDatabase) {
|
||||
throw new Error("Memory database not initialized");
|
||||
}
|
||||
export { saveMemoryDatabaseToFile };
|
||||
|
||||
try {
|
||||
// Export in-memory database to buffer
|
||||
const buffer = memoryDatabase.serialize();
|
||||
return buffer;
|
||||
} catch (error) {
|
||||
databaseLogger.error(
|
||||
"Failed to serialize memory database to buffer",
|
||||
error,
|
||||
{
|
||||
operation: "memory_db_serialize_failed",
|
||||
},
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Export save function for manual saves and buffer access
|
||||
export { saveMemoryDatabaseToFile, getMemoryDatabaseBuffer };
|
||||
|
||||
// Export database save trigger for real-time saves
|
||||
export { DatabaseSaveTrigger };
|
||||
|
||||
@@ -1,600 +0,0 @@
|
||||
import { drizzle } from "drizzle-orm/better-sqlite3";
|
||||
import Database from "better-sqlite3";
|
||||
import * as schema from "./schema.js";
|
||||
import { databaseLogger } from "../../utils/logger.js";
|
||||
import { UserDatabaseManager } from "../../utils/user-database-manager.js";
|
||||
|
||||
// Global database manager instance
|
||||
const databaseManager = UserDatabaseManager.getInstance();
|
||||
|
||||
/**
|
||||
* Initialize database system - simplified for user-based architecture
|
||||
*/
|
||||
async function initializeDatabase(): Promise<void> {
|
||||
try {
|
||||
databaseLogger.info("Initializing database system (user-based architecture)", {
|
||||
operation: "db_init_v3",
|
||||
});
|
||||
|
||||
// Initialize system database (unencrypted)
|
||||
await databaseManager.initializeSystem();
|
||||
|
||||
databaseLogger.success("Database system initialized successfully", {
|
||||
operation: "db_init_v3_success",
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to initialize database system", error, {
|
||||
operation: "db_init_v3_failed",
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Export a promise that resolves when database is fully initialized
|
||||
export const databaseReady = initializeDatabase()
|
||||
.then(() => {
|
||||
databaseLogger.success("Database system ready", {
|
||||
operation: "db_ready",
|
||||
architecture: "v3-user-based",
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
databaseLogger.error("Failed to initialize database system", error, {
|
||||
operation: "db_ready_failed",
|
||||
});
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
databaseLogger.info(`Initializing SQLite database`, {
|
||||
operation: "db_init",
|
||||
path: actualDbPath,
|
||||
encrypted:
|
||||
enableFileEncryption &&
|
||||
DatabaseFileEncryption.isEncryptedDatabaseFile(encryptedDbPath),
|
||||
inMemory: true,
|
||||
isNewDatabase,
|
||||
});
|
||||
|
||||
// Create module-level sqlite instance after database is initialized
|
||||
sqlite = memoryDatabase;
|
||||
|
||||
// Initialize drizzle ORM with the configured database
|
||||
db = drizzle(sqlite, { schema });
|
||||
|
||||
databaseLogger.info("Database ORM initialized", {
|
||||
operation: "drizzle_init",
|
||||
tablesConfigured: Object.keys(schema).length
|
||||
});
|
||||
|
||||
sqlite.exec(`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
is_admin INTEGER NOT NULL DEFAULT 0,
|
||||
is_oidc INTEGER NOT NULL DEFAULT 0,
|
||||
client_id TEXT NOT NULL,
|
||||
client_secret TEXT NOT NULL,
|
||||
issuer_url TEXT NOT NULL,
|
||||
authorization_url TEXT NOT NULL,
|
||||
token_url TEXT NOT NULL,
|
||||
redirect_uri TEXT,
|
||||
identifier_path TEXT NOT NULL,
|
||||
name_path TEXT NOT NULL,
|
||||
scopes TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ssh_data (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
name TEXT,
|
||||
ip TEXT NOT NULL,
|
||||
port INTEGER NOT NULL,
|
||||
username TEXT NOT NULL,
|
||||
folder TEXT,
|
||||
tags TEXT,
|
||||
pin INTEGER NOT NULL DEFAULT 0,
|
||||
auth_type TEXT NOT NULL,
|
||||
password TEXT,
|
||||
key TEXT,
|
||||
key_password TEXT,
|
||||
key_type TEXT,
|
||||
enable_terminal INTEGER NOT NULL DEFAULT 1,
|
||||
enable_tunnel INTEGER NOT NULL DEFAULT 1,
|
||||
tunnel_connections TEXT,
|
||||
enable_file_manager INTEGER NOT NULL DEFAULT 1,
|
||||
default_path TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS file_manager_recent (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
host_id INTEGER NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
path TEXT NOT NULL,
|
||||
last_opened TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id),
|
||||
FOREIGN KEY (host_id) REFERENCES ssh_data (id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS file_manager_pinned (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
host_id INTEGER NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
path TEXT NOT NULL,
|
||||
pinned_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id),
|
||||
FOREIGN KEY (host_id) REFERENCES ssh_data (id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS file_manager_shortcuts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
host_id INTEGER NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
path TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id),
|
||||
FOREIGN KEY (host_id) REFERENCES ssh_data (id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS dismissed_alerts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
alert_id TEXT NOT NULL,
|
||||
dismissed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ssh_credentials (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
folder TEXT,
|
||||
tags TEXT,
|
||||
auth_type TEXT NOT NULL,
|
||||
username TEXT NOT NULL,
|
||||
password TEXT,
|
||||
key TEXT,
|
||||
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,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ssh_credential_usage (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
credential_id INTEGER NOT NULL,
|
||||
host_id INTEGER NOT NULL,
|
||||
user_id TEXT NOT NULL,
|
||||
used_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (credential_id) REFERENCES ssh_credentials (id),
|
||||
FOREIGN KEY (host_id) REFERENCES ssh_data (id),
|
||||
FOREIGN KEY (user_id) REFERENCES users (id)
|
||||
);
|
||||
`);
|
||||
|
||||
// Run schema migrations
|
||||
migrateSchema();
|
||||
|
||||
// Initialize default settings
|
||||
try {
|
||||
const row = sqlite
|
||||
.prepare("SELECT value FROM settings WHERE key = 'allow_registration'")
|
||||
.get();
|
||||
if (!row) {
|
||||
databaseLogger.info("Initializing default settings", {
|
||||
operation: "db_init",
|
||||
setting: "allow_registration",
|
||||
});
|
||||
sqlite
|
||||
.prepare(
|
||||
"INSERT INTO settings (key, value) VALUES ('allow_registration', 'true')",
|
||||
)
|
||||
.run();
|
||||
}
|
||||
} catch (e) {
|
||||
databaseLogger.warn("Could not initialize default settings", {
|
||||
operation: "db_init",
|
||||
error: e,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const addColumnIfNotExists = (
|
||||
table: string,
|
||||
column: string,
|
||||
definition: string,
|
||||
) => {
|
||||
try {
|
||||
sqlite
|
||||
.prepare(
|
||||
`SELECT ${column}
|
||||
FROM ${table} LIMIT 1`,
|
||||
)
|
||||
.get();
|
||||
} catch (e) {
|
||||
try {
|
||||
databaseLogger.debug(`Adding column ${column} to ${table}`, {
|
||||
operation: "schema_migration",
|
||||
table,
|
||||
column,
|
||||
});
|
||||
sqlite.exec(`ALTER TABLE ${table}
|
||||
ADD COLUMN ${column} ${definition};`);
|
||||
databaseLogger.success(`Column ${column} added to ${table}`, {
|
||||
operation: "schema_migration",
|
||||
table,
|
||||
column,
|
||||
});
|
||||
} catch (alterError) {
|
||||
databaseLogger.warn(`Failed to add column ${column} to ${table}`, {
|
||||
operation: "schema_migration",
|
||||
table,
|
||||
column,
|
||||
error: alterError,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const migrateSchema = () => {
|
||||
databaseLogger.info("Checking for schema updates...", {
|
||||
operation: "schema_migration",
|
||||
});
|
||||
|
||||
addColumnIfNotExists("users", "is_admin", "INTEGER NOT NULL DEFAULT 0");
|
||||
|
||||
addColumnIfNotExists("users", "is_oidc", "INTEGER NOT NULL DEFAULT 0");
|
||||
addColumnIfNotExists("users", "oidc_identifier", "TEXT");
|
||||
addColumnIfNotExists("users", "client_id", "TEXT");
|
||||
addColumnIfNotExists("users", "client_secret", "TEXT");
|
||||
addColumnIfNotExists("users", "issuer_url", "TEXT");
|
||||
addColumnIfNotExists("users", "authorization_url", "TEXT");
|
||||
addColumnIfNotExists("users", "token_url", "TEXT");
|
||||
|
||||
addColumnIfNotExists("users", "identifier_path", "TEXT");
|
||||
addColumnIfNotExists("users", "name_path", "TEXT");
|
||||
addColumnIfNotExists("users", "scopes", "TEXT");
|
||||
|
||||
addColumnIfNotExists("users", "totp_secret", "TEXT");
|
||||
addColumnIfNotExists("users", "totp_enabled", "INTEGER NOT NULL DEFAULT 0");
|
||||
addColumnIfNotExists("users", "totp_backup_codes", "TEXT");
|
||||
|
||||
addColumnIfNotExists("ssh_data", "name", "TEXT");
|
||||
addColumnIfNotExists("ssh_data", "folder", "TEXT");
|
||||
addColumnIfNotExists("ssh_data", "tags", "TEXT");
|
||||
addColumnIfNotExists("ssh_data", "pin", "INTEGER NOT NULL DEFAULT 0");
|
||||
addColumnIfNotExists(
|
||||
"ssh_data",
|
||||
"auth_type",
|
||||
'TEXT NOT NULL DEFAULT "password"',
|
||||
);
|
||||
addColumnIfNotExists("ssh_data", "password", "TEXT");
|
||||
addColumnIfNotExists("ssh_data", "key", "TEXT");
|
||||
addColumnIfNotExists("ssh_data", "key_password", "TEXT");
|
||||
addColumnIfNotExists("ssh_data", "key_type", "TEXT");
|
||||
addColumnIfNotExists(
|
||||
"ssh_data",
|
||||
"enable_terminal",
|
||||
"INTEGER NOT NULL DEFAULT 1",
|
||||
);
|
||||
addColumnIfNotExists(
|
||||
"ssh_data",
|
||||
"enable_tunnel",
|
||||
"INTEGER NOT NULL DEFAULT 1",
|
||||
);
|
||||
addColumnIfNotExists("ssh_data", "tunnel_connections", "TEXT");
|
||||
addColumnIfNotExists(
|
||||
"ssh_data",
|
||||
"enable_file_manager",
|
||||
"INTEGER NOT NULL DEFAULT 1",
|
||||
);
|
||||
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",
|
||||
);
|
||||
|
||||
addColumnIfNotExists(
|
||||
"ssh_data",
|
||||
"credential_id",
|
||||
"INTEGER REFERENCES ssh_credentials(id)",
|
||||
);
|
||||
|
||||
|
||||
// SSH credentials table migrations for encryption support
|
||||
addColumnIfNotExists("ssh_credentials", "private_key", "TEXT");
|
||||
addColumnIfNotExists("ssh_credentials", "public_key", "TEXT");
|
||||
addColumnIfNotExists("ssh_credentials", "detected_key_type", "TEXT");
|
||||
|
||||
addColumnIfNotExists("file_manager_recent", "host_id", "INTEGER NOT NULL");
|
||||
addColumnIfNotExists("file_manager_pinned", "host_id", "INTEGER NOT NULL");
|
||||
addColumnIfNotExists("file_manager_shortcuts", "host_id", "INTEGER NOT NULL");
|
||||
|
||||
databaseLogger.success("Schema migration completed", {
|
||||
operation: "schema_migration",
|
||||
});
|
||||
};
|
||||
|
||||
// Function to save in-memory database to encrypted file
|
||||
async function saveMemoryDatabaseToFile() {
|
||||
if (!memoryDatabase || !enableFileEncryption) return;
|
||||
|
||||
try {
|
||||
// Export in-memory database to buffer
|
||||
const buffer = memoryDatabase.serialize();
|
||||
|
||||
// Encrypt and save to file (now async)
|
||||
await DatabaseFileEncryption.encryptDatabaseFromBuffer(buffer, encryptedDbPath);
|
||||
|
||||
databaseLogger.debug("In-memory database saved to encrypted file", {
|
||||
operation: "memory_db_save",
|
||||
bufferSize: buffer.length,
|
||||
encryptedPath: encryptedDbPath,
|
||||
});
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to save in-memory database", error, {
|
||||
operation: "memory_db_save_failed",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Function to handle post-initialization file encryption and cleanup
|
||||
async function handlePostInitFileEncryption() {
|
||||
if (!enableFileEncryption) return;
|
||||
|
||||
try {
|
||||
// Clean up any existing unencrypted database files
|
||||
if (fs.existsSync(dbPath)) {
|
||||
databaseLogger.warn(
|
||||
"Found unencrypted database file, removing for security",
|
||||
{
|
||||
operation: "db_security_cleanup_existing",
|
||||
removingPath: dbPath,
|
||||
},
|
||||
);
|
||||
|
||||
try {
|
||||
fs.unlinkSync(dbPath);
|
||||
databaseLogger.success(
|
||||
"Unencrypted database file removed for security",
|
||||
{
|
||||
operation: "db_security_cleanup_complete",
|
||||
removedPath: dbPath,
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
databaseLogger.warn(
|
||||
"Could not remove unencrypted database file (may be locked)",
|
||||
{
|
||||
operation: "db_security_cleanup_deferred",
|
||||
path: dbPath,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
},
|
||||
);
|
||||
|
||||
// Try again after a short delay
|
||||
setTimeout(() => {
|
||||
try {
|
||||
if (fs.existsSync(dbPath)) {
|
||||
fs.unlinkSync(dbPath);
|
||||
databaseLogger.success(
|
||||
"Delayed cleanup: unencrypted database file removed",
|
||||
{
|
||||
operation: "db_security_cleanup_delayed_success",
|
||||
removedPath: dbPath,
|
||||
},
|
||||
);
|
||||
}
|
||||
} catch (delayedError) {
|
||||
databaseLogger.error(
|
||||
"Failed to remove unencrypted database file even after delay",
|
||||
delayedError,
|
||||
{
|
||||
operation: "db_security_cleanup_delayed_failed",
|
||||
path: dbPath,
|
||||
},
|
||||
);
|
||||
}
|
||||
}, 2000);
|
||||
}
|
||||
}
|
||||
|
||||
// Always save the in-memory database (whether new or existing)
|
||||
if (memoryDatabase) {
|
||||
// Save immediately after initialization
|
||||
await saveMemoryDatabaseToFile();
|
||||
|
||||
// Set up periodic saves every 5 minutes
|
||||
setInterval(saveMemoryDatabaseToFile, 5 * 60 * 1000);
|
||||
}
|
||||
} catch (error) {
|
||||
databaseLogger.error(
|
||||
"Failed to handle database file encryption/cleanup",
|
||||
error,
|
||||
{
|
||||
operation: "db_encrypt_cleanup_failed",
|
||||
},
|
||||
);
|
||||
|
||||
// Don't fail the entire initialization for this
|
||||
}
|
||||
}
|
||||
|
||||
// Export a promise that resolves when database is fully initialized
|
||||
export const databaseReady = initializeCompleteDatabase()
|
||||
.then(async () => {
|
||||
await handlePostInitFileEncryption();
|
||||
|
||||
databaseLogger.success("Database connection established", {
|
||||
operation: "db_init",
|
||||
path: actualDbPath,
|
||||
hasEncryptedBackup:
|
||||
enableFileEncryption &&
|
||||
DatabaseFileEncryption.isEncryptedDatabaseFile(encryptedDbPath),
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
databaseLogger.error("Failed to initialize database", error, {
|
||||
operation: "db_init",
|
||||
});
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
// Cleanup function for database and temporary files
|
||||
async function cleanupDatabase() {
|
||||
// Save in-memory database before closing
|
||||
if (memoryDatabase) {
|
||||
try {
|
||||
await saveMemoryDatabaseToFile();
|
||||
} catch (error) {
|
||||
databaseLogger.error(
|
||||
"Failed to save in-memory database before shutdown",
|
||||
error,
|
||||
{
|
||||
operation: "shutdown_save_failed",
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Close database connection
|
||||
try {
|
||||
if (sqlite) {
|
||||
sqlite.close();
|
||||
databaseLogger.debug("Database connection closed", {
|
||||
operation: "db_close",
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
databaseLogger.warn("Error closing database connection", {
|
||||
operation: "db_close_error",
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
|
||||
// Clean up temp directory
|
||||
try {
|
||||
const tempDir = path.join(dataDir, ".temp");
|
||||
if (fs.existsSync(tempDir)) {
|
||||
const files = fs.readdirSync(tempDir);
|
||||
for (const file of files) {
|
||||
try {
|
||||
fs.unlinkSync(path.join(tempDir, file));
|
||||
} catch {
|
||||
// Ignore individual file cleanup errors
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
fs.rmdirSync(tempDir);
|
||||
databaseLogger.debug("Temp directory cleaned up", {
|
||||
operation: "temp_dir_cleanup",
|
||||
});
|
||||
} catch {
|
||||
// Ignore directory removal errors
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Ignore temp directory cleanup errors
|
||||
}
|
||||
}
|
||||
|
||||
// Register cleanup handlers
|
||||
process.on("exit", () => {
|
||||
// Synchronous cleanup only for exit event
|
||||
if (sqlite) {
|
||||
try {
|
||||
sqlite.close();
|
||||
} catch {}
|
||||
}
|
||||
});
|
||||
|
||||
process.on("SIGINT", async () => {
|
||||
databaseLogger.info("Received SIGINT, cleaning up...", {
|
||||
operation: "shutdown",
|
||||
});
|
||||
await cleanupDatabase();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
process.on("SIGTERM", async () => {
|
||||
databaseLogger.info("Received SIGTERM, cleaning up...", {
|
||||
operation: "shutdown",
|
||||
});
|
||||
await cleanupDatabase();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
// Database connection - will be initialized after database setup
|
||||
let db: ReturnType<typeof drizzle<typeof schema>>;
|
||||
|
||||
// Export database connection getter function to avoid undefined access
|
||||
export function getDb(): ReturnType<typeof drizzle<typeof schema>> {
|
||||
if (!db) {
|
||||
throw new Error("Database not initialized. Ensure databaseReady promise is awaited before accessing db.");
|
||||
}
|
||||
return db;
|
||||
}
|
||||
|
||||
// Legacy export for compatibility - will throw if accessed before initialization
|
||||
export { db };
|
||||
export { DatabaseFileEncryption };
|
||||
export const databasePaths = {
|
||||
main: actualDbPath,
|
||||
encrypted: encryptedDbPath,
|
||||
directory: dbDir,
|
||||
inMemory: true,
|
||||
};
|
||||
|
||||
// Memory database buffer function
|
||||
function getMemoryDatabaseBuffer(): Buffer {
|
||||
if (!memoryDatabase) {
|
||||
throw new Error("Memory database not initialized");
|
||||
}
|
||||
|
||||
try {
|
||||
// Export in-memory database to buffer
|
||||
const buffer = memoryDatabase.serialize();
|
||||
|
||||
databaseLogger.debug("Memory database serialized to buffer", {
|
||||
operation: "memory_db_serialize",
|
||||
bufferSize: buffer.length,
|
||||
});
|
||||
|
||||
return buffer;
|
||||
} catch (error) {
|
||||
databaseLogger.error(
|
||||
"Failed to serialize memory database to buffer",
|
||||
error,
|
||||
{
|
||||
operation: "memory_db_serialize_failed",
|
||||
},
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Export save function for manual saves and buffer access
|
||||
export { saveMemoryDatabaseToFile, getMemoryDatabaseBuffer };
|
||||
@@ -49,7 +49,6 @@ export const sshData = sqliteTable("ssh_data", {
|
||||
keyPassword: text("key_password"),
|
||||
keyType: text("key_type"),
|
||||
|
||||
// AutoStart plaintext fields (populated only when autoStart is enabled)
|
||||
autostartPassword: text("autostart_password"),
|
||||
autostartKey: text("autostart_key", { length: 8192 }),
|
||||
autostartKeyPassword: text("autostart_key_password"),
|
||||
@@ -142,7 +141,7 @@ export const sshCredentials = sqliteTable("ssh_credentials", {
|
||||
authType: text("auth_type").notNull(),
|
||||
username: text("username").notNull(),
|
||||
password: text("password"),
|
||||
key: text("key", { length: 16384 }), // backward compatibility
|
||||
key: text("key", { length: 16384 }),
|
||||
privateKey: text("private_key", { length: 16384 }),
|
||||
publicKey: text("public_key", { length: 4096 }),
|
||||
keyPassword: text("key_password"),
|
||||
@@ -173,4 +172,3 @@ export const sshCredentialUsage = sqliteTable("ssh_credential_usage", {
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
|
||||
|
||||
@@ -108,7 +108,6 @@ async function fetchAlertsFromGitHub(): Promise<TermixAlert[]> {
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Initialize auth middleware
|
||||
const authManager = AuthManager.getInstance();
|
||||
const authenticateJWT = authManager.createAuthMiddleware();
|
||||
|
||||
@@ -144,8 +143,6 @@ router.get("/", authenticateJWT, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Deprecated endpoint - use GET /alerts instead
|
||||
|
||||
// Route: Dismiss an alert for the authenticated user
|
||||
// POST /alerts/dismiss
|
||||
router.post("/dismiss", authenticateJWT, async (req, res) => {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -23,10 +23,6 @@ const router = express.Router();
|
||||
|
||||
const upload = multer({ storage: multer.memoryStorage() });
|
||||
|
||||
interface JWTPayload {
|
||||
userId: string;
|
||||
}
|
||||
|
||||
function isNonEmptyString(value: any): value is string {
|
||||
return typeof value === "string" && value.trim().length > 0;
|
||||
}
|
||||
@@ -35,26 +31,25 @@ function isValidPort(port: any): port is number {
|
||||
return typeof port === "number" && port > 0 && port <= 65535;
|
||||
}
|
||||
|
||||
// Use AuthManager middleware for authentication
|
||||
const authManager = AuthManager.getInstance();
|
||||
const authenticateJWT = authManager.createAuthMiddleware();
|
||||
const requireDataAccess = authManager.createDataAccessMiddleware();
|
||||
|
||||
|
||||
// Internal-only endpoint for autostart - requires internal auth token
|
||||
router.get("/db/host/internal", async (req: Request, res: Response) => {
|
||||
try {
|
||||
// Check for internal authentication token using SystemCrypto
|
||||
const internalToken = req.headers["x-internal-auth-token"];
|
||||
const systemCrypto = SystemCrypto.getInstance();
|
||||
const expectedToken = await systemCrypto.getInternalAuthToken();
|
||||
|
||||
if (internalToken !== expectedToken) {
|
||||
sshLogger.warn("Unauthorized attempt to access internal SSH host endpoint", {
|
||||
source: req.ip,
|
||||
userAgent: req.headers["user-agent"],
|
||||
providedToken: internalToken ? "present" : "missing"
|
||||
});
|
||||
sshLogger.warn(
|
||||
"Unauthorized attempt to access internal SSH host endpoint",
|
||||
{
|
||||
source: req.ip,
|
||||
userAgent: req.headers["user-agent"],
|
||||
providedToken: internalToken ? "present" : "missing",
|
||||
},
|
||||
);
|
||||
return res.status(403).json({ error: "Forbidden" });
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -63,25 +58,16 @@ router.get("/db/host/internal", async (req: Request, res: Response) => {
|
||||
}
|
||||
|
||||
try {
|
||||
// Query sshData directly for hosts that have autostart plaintext fields populated
|
||||
const autostartHosts = await db.select()
|
||||
const autostartHosts = await db
|
||||
.select()
|
||||
.from(sshData)
|
||||
.where(
|
||||
// Check if any autostart fields are populated (meaning autostart is enabled)
|
||||
or(
|
||||
isNotNull(sshData.autostartPassword),
|
||||
isNotNull(sshData.autostartKey)
|
||||
)
|
||||
isNotNull(sshData.autostartKey),
|
||||
),
|
||||
);
|
||||
|
||||
sshLogger.info("Internal autostart endpoint accessed", {
|
||||
operation: "autostart_internal_access",
|
||||
configCount: autostartHosts.length,
|
||||
source: req.ip,
|
||||
userAgent: req.headers["user-agent"]
|
||||
});
|
||||
|
||||
// Transform to expected format for tunnel service
|
||||
const result = autostartHosts.map((host) => {
|
||||
const tunnelConnections = host.tunnelConnections
|
||||
? JSON.parse(host.tunnelConnections)
|
||||
@@ -97,13 +83,14 @@ router.get("/db/host/internal", async (req: Request, res: Response) => {
|
||||
password: host.autostartPassword,
|
||||
key: host.autostartKey,
|
||||
keyPassword: host.autostartKeyPassword,
|
||||
// Include explicit autostart fields for tunnel service
|
||||
autostartPassword: host.autostartPassword,
|
||||
autostartKey: host.autostartKey,
|
||||
autostartKeyPassword: host.autostartKeyPassword,
|
||||
authType: host.authType,
|
||||
enableTunnel: true,
|
||||
tunnelConnections: tunnelConnections.filter((tunnel: any) => tunnel.autoStart),
|
||||
tunnelConnections: tunnelConnections.filter(
|
||||
(tunnel: any) => tunnel.autoStart,
|
||||
),
|
||||
pin: false,
|
||||
enableTerminal: false,
|
||||
enableFileManager: false,
|
||||
@@ -118,33 +105,26 @@ router.get("/db/host/internal", async (req: Request, res: Response) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Internal-only endpoint for all hosts - requires internal auth token (for tunnel endpointHost resolution)
|
||||
router.get("/db/host/internal/all", async (req: Request, res: Response) => {
|
||||
try {
|
||||
// Check for internal authentication token using SystemCrypto
|
||||
const internalToken = req.headers["x-internal-auth-token"];
|
||||
if (!internalToken) {
|
||||
return res.status(401).json({ error: "Internal authentication token required" });
|
||||
return res
|
||||
.status(401)
|
||||
.json({ error: "Internal authentication token required" });
|
||||
}
|
||||
|
||||
const systemCrypto = SystemCrypto.getInstance();
|
||||
const expectedToken = await systemCrypto.getInternalAuthToken();
|
||||
|
||||
if (internalToken !== expectedToken) {
|
||||
return res.status(401).json({ error: "Invalid internal authentication token" });
|
||||
return res
|
||||
.status(401)
|
||||
.json({ error: "Invalid internal authentication token" });
|
||||
}
|
||||
|
||||
// Query all hosts for endpointHost resolution
|
||||
const allHosts = await db.select().from(sshData);
|
||||
|
||||
sshLogger.info("Internal all hosts endpoint accessed", {
|
||||
operation: "all_hosts_internal_access",
|
||||
hostCount: allHosts.length,
|
||||
source: req.ip,
|
||||
userAgent: req.headers["user-agent"]
|
||||
});
|
||||
|
||||
// Transform to expected format for tunnel service
|
||||
const result = allHosts.map((host) => {
|
||||
const tunnelConnections = host.tunnelConnections
|
||||
? JSON.parse(host.tunnelConnections)
|
||||
@@ -160,7 +140,6 @@ router.get("/db/host/internal/all", async (req: Request, res: Response) => {
|
||||
password: host.autostartPassword || host.password,
|
||||
key: host.autostartKey || host.key,
|
||||
keyPassword: host.autostartKeyPassword || host.keyPassword,
|
||||
// Include autostart fields for fallback
|
||||
autostartPassword: host.autostartPassword,
|
||||
autostartKey: host.autostartKey,
|
||||
autostartKeyPassword: host.autostartKeyPassword,
|
||||
@@ -291,7 +270,6 @@ router.post(
|
||||
sshDataObj.keyType = keyType;
|
||||
sshDataObj.password = null;
|
||||
} else {
|
||||
// For credential auth
|
||||
sshDataObj.password = null;
|
||||
sshDataObj.key = null;
|
||||
sshDataObj.keyPassword = null;
|
||||
@@ -1381,103 +1359,116 @@ router.post(
|
||||
const { sshConfigId } = req.body;
|
||||
|
||||
if (!sshConfigId || typeof sshConfigId !== "number") {
|
||||
sshLogger.warn("Missing or invalid sshConfigId in autostart enable request", {
|
||||
operation: "autostart_enable",
|
||||
userId,
|
||||
sshConfigId
|
||||
});
|
||||
sshLogger.warn(
|
||||
"Missing or invalid sshConfigId in autostart enable request",
|
||||
{
|
||||
operation: "autostart_enable",
|
||||
userId,
|
||||
sshConfigId,
|
||||
},
|
||||
);
|
||||
return res.status(400).json({ error: "Valid sshConfigId is required" });
|
||||
}
|
||||
|
||||
try {
|
||||
// Validate user has access to decrypt the data
|
||||
const userDataKey = DataCrypto.getUserDataKey(userId);
|
||||
if (!userDataKey) {
|
||||
sshLogger.warn("User attempted to enable autostart without unlocked data", {
|
||||
operation: "autostart_enable_failed",
|
||||
userId,
|
||||
sshConfigId,
|
||||
reason: "data_locked"
|
||||
});
|
||||
sshLogger.warn(
|
||||
"User attempted to enable autostart without unlocked data",
|
||||
{
|
||||
operation: "autostart_enable_failed",
|
||||
userId,
|
||||
sshConfigId,
|
||||
reason: "data_locked",
|
||||
},
|
||||
);
|
||||
return res.status(400).json({
|
||||
error: "Failed to enable autostart. Ensure user data is unlocked."
|
||||
error: "Failed to enable autostart. Ensure user data is unlocked.",
|
||||
});
|
||||
}
|
||||
|
||||
// Get and decrypt SSH configuration
|
||||
const sshConfig = await db.select()
|
||||
const sshConfig = await db
|
||||
.select()
|
||||
.from(sshData)
|
||||
.where(and(
|
||||
eq(sshData.id, sshConfigId),
|
||||
eq(sshData.userId, userId)
|
||||
));
|
||||
.where(and(eq(sshData.id, sshConfigId), eq(sshData.userId, userId)));
|
||||
|
||||
if (sshConfig.length === 0) {
|
||||
sshLogger.warn("SSH config not found for autostart enable", {
|
||||
operation: "autostart_enable_failed",
|
||||
userId,
|
||||
sshConfigId,
|
||||
reason: "config_not_found"
|
||||
reason: "config_not_found",
|
||||
});
|
||||
return res.status(404).json({
|
||||
error: "SSH configuration not found"
|
||||
error: "SSH configuration not found",
|
||||
});
|
||||
}
|
||||
|
||||
const config = sshConfig[0];
|
||||
|
||||
// Decrypt sensitive fields
|
||||
const decryptedConfig = DataCrypto.decryptRecord("ssh_data", config, userId, userDataKey);
|
||||
const decryptedConfig = DataCrypto.decryptRecord(
|
||||
"ssh_data",
|
||||
config,
|
||||
userId,
|
||||
userDataKey,
|
||||
);
|
||||
|
||||
// Also handle tunnel connections - populate endpoint credentials
|
||||
let updatedTunnelConnections = config.tunnelConnections;
|
||||
if (config.tunnelConnections) {
|
||||
try {
|
||||
const tunnelConnections = JSON.parse(config.tunnelConnections);
|
||||
|
||||
// For each tunnel connection, try to resolve endpoint credentials
|
||||
const resolvedConnections = await Promise.all(
|
||||
tunnelConnections.map(async (tunnel: any) => {
|
||||
if (tunnel.autoStart && tunnel.endpointHost && !tunnel.endpointPassword && !tunnel.endpointKey) {
|
||||
// Find endpoint host by name or username@ip
|
||||
const endpointHosts = await db.select()
|
||||
if (
|
||||
tunnel.autoStart &&
|
||||
tunnel.endpointHost &&
|
||||
!tunnel.endpointPassword &&
|
||||
!tunnel.endpointKey
|
||||
) {
|
||||
const endpointHosts = await db
|
||||
.select()
|
||||
.from(sshData)
|
||||
.where(eq(sshData.userId, userId));
|
||||
|
||||
const endpointHost = endpointHosts.find(h =>
|
||||
h.name === tunnel.endpointHost ||
|
||||
`${h.username}@${h.ip}` === tunnel.endpointHost
|
||||
const endpointHost = endpointHosts.find(
|
||||
(h) =>
|
||||
h.name === tunnel.endpointHost ||
|
||||
`${h.username}@${h.ip}` === tunnel.endpointHost,
|
||||
);
|
||||
|
||||
if (endpointHost) {
|
||||
// Decrypt endpoint host credentials
|
||||
const decryptedEndpoint = DataCrypto.decryptRecord("ssh_data", endpointHost, userId, userDataKey);
|
||||
const decryptedEndpoint = DataCrypto.decryptRecord(
|
||||
"ssh_data",
|
||||
endpointHost,
|
||||
userId,
|
||||
userDataKey,
|
||||
);
|
||||
|
||||
// Add endpoint credentials to tunnel connection
|
||||
return {
|
||||
...tunnel,
|
||||
endpointPassword: decryptedEndpoint.password || null,
|
||||
endpointKey: decryptedEndpoint.key || null,
|
||||
endpointKeyPassword: decryptedEndpoint.keyPassword || null,
|
||||
endpointAuthType: endpointHost.authType
|
||||
endpointAuthType: endpointHost.authType,
|
||||
};
|
||||
}
|
||||
}
|
||||
return tunnel;
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
updatedTunnelConnections = JSON.stringify(resolvedConnections);
|
||||
} catch (error) {
|
||||
sshLogger.warn("Failed to update tunnel connections", {
|
||||
operation: "tunnel_connections_update_failed",
|
||||
error: error instanceof Error ? error.message : "Unknown error"
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Update the SSH config with plaintext autostart fields and resolved tunnel connections
|
||||
const updateResult = await db.update(sshData)
|
||||
const updateResult = await db
|
||||
.update(sshData)
|
||||
.set({
|
||||
autostartPassword: decryptedConfig.password || null,
|
||||
autostartKey: decryptedConfig.key || null,
|
||||
@@ -1486,36 +1477,29 @@ router.post(
|
||||
})
|
||||
.where(eq(sshData.id, sshConfigId));
|
||||
|
||||
// Force database save after autostart update
|
||||
try {
|
||||
await DatabaseSaveTrigger.triggerSave();
|
||||
} catch (saveError) {
|
||||
sshLogger.warn("Database save failed after autostart", {
|
||||
operation: "autostart_db_save_failed",
|
||||
error: saveError instanceof Error ? saveError.message : "Unknown error"
|
||||
error:
|
||||
saveError instanceof Error ? saveError.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
|
||||
sshLogger.success("AutoStart enabled successfully", {
|
||||
operation: "autostart_enabled",
|
||||
userId,
|
||||
sshConfigId,
|
||||
host: config.ip
|
||||
});
|
||||
|
||||
res.json({
|
||||
message: "AutoStart enabled successfully",
|
||||
sshConfigId
|
||||
sshConfigId,
|
||||
});
|
||||
} catch (error) {
|
||||
sshLogger.error("Error enabling autostart", error, {
|
||||
operation: "autostart_enable_error",
|
||||
userId,
|
||||
sshConfigId
|
||||
sshConfigId,
|
||||
});
|
||||
res.status(500).json({ error: "Internal server error" });
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Route: Disable autostart for SSH configuration (requires JWT)
|
||||
@@ -1528,46 +1512,40 @@ router.delete(
|
||||
const { sshConfigId } = req.body;
|
||||
|
||||
if (!sshConfigId || typeof sshConfigId !== "number") {
|
||||
sshLogger.warn("Missing or invalid sshConfigId in autostart disable request", {
|
||||
operation: "autostart_disable",
|
||||
userId,
|
||||
sshConfigId
|
||||
});
|
||||
sshLogger.warn(
|
||||
"Missing or invalid sshConfigId in autostart disable request",
|
||||
{
|
||||
operation: "autostart_disable",
|
||||
userId,
|
||||
sshConfigId,
|
||||
},
|
||||
);
|
||||
return res.status(400).json({ error: "Valid sshConfigId is required" });
|
||||
}
|
||||
|
||||
try {
|
||||
// Clear the autostart plaintext fields for this SSH config
|
||||
const result = await db.update(sshData)
|
||||
const result = await db
|
||||
.update(sshData)
|
||||
.set({
|
||||
autostartPassword: null,
|
||||
autostartKey: null,
|
||||
autostartKeyPassword: null,
|
||||
})
|
||||
.where(and(
|
||||
eq(sshData.id, sshConfigId),
|
||||
eq(sshData.userId, userId)
|
||||
));
|
||||
|
||||
sshLogger.info("AutoStart disabled successfully", {
|
||||
operation: "autostart_disabled",
|
||||
userId,
|
||||
sshConfigId
|
||||
});
|
||||
.where(and(eq(sshData.id, sshConfigId), eq(sshData.userId, userId)));
|
||||
|
||||
res.json({
|
||||
message: "AutoStart disabled successfully",
|
||||
sshConfigId
|
||||
sshConfigId,
|
||||
});
|
||||
} catch (error) {
|
||||
sshLogger.error("Error disabling autostart", error, {
|
||||
operation: "autostart_disable_error",
|
||||
userId,
|
||||
sshConfigId
|
||||
sshConfigId,
|
||||
});
|
||||
res.status(500).json({ error: "Internal server error" });
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Route: Get autostart status for user's SSH configurations (requires JWT)
|
||||
@@ -1579,44 +1557,39 @@ router.get(
|
||||
const userId = (req as any).userId;
|
||||
|
||||
try {
|
||||
// Query user's SSH configs that have autostart enabled
|
||||
const autostartConfigs = await db.select()
|
||||
const autostartConfigs = await db
|
||||
.select()
|
||||
.from(sshData)
|
||||
.where(and(
|
||||
eq(sshData.userId, userId),
|
||||
or(
|
||||
isNotNull(sshData.autostartPassword),
|
||||
isNotNull(sshData.autostartKey)
|
||||
)
|
||||
));
|
||||
.where(
|
||||
and(
|
||||
eq(sshData.userId, userId),
|
||||
or(
|
||||
isNotNull(sshData.autostartPassword),
|
||||
isNotNull(sshData.autostartKey),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// Map to just the basic info needed for status
|
||||
const statusList = autostartConfigs.map(config => ({
|
||||
const statusList = autostartConfigs.map((config) => ({
|
||||
sshConfigId: config.id,
|
||||
host: config.ip,
|
||||
port: config.port,
|
||||
username: config.username,
|
||||
authType: config.authType
|
||||
authType: config.authType,
|
||||
}));
|
||||
|
||||
sshLogger.info("AutoStart status retrieved", {
|
||||
operation: "autostart_status",
|
||||
userId,
|
||||
configCount: statusList.length
|
||||
});
|
||||
|
||||
res.json({
|
||||
autostart_configs: statusList,
|
||||
total_count: statusList.length
|
||||
total_count: statusList.length,
|
||||
});
|
||||
} catch (error) {
|
||||
sshLogger.error("Error getting autostart status", error, {
|
||||
operation: "autostart_status_error",
|
||||
userId
|
||||
userId,
|
||||
});
|
||||
res.status(500).json({ error: "Internal server error" });
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -13,16 +13,13 @@ import {
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import bcrypt from "bcryptjs";
|
||||
import { nanoid } from "nanoid";
|
||||
import jwt from "jsonwebtoken";
|
||||
import speakeasy from "speakeasy";
|
||||
import QRCode from "qrcode";
|
||||
import type { Request, Response, NextFunction } from "express";
|
||||
import { authLogger, apiLogger } from "../../utils/logger.js";
|
||||
import type { Request, Response } from "express";
|
||||
import { authLogger } from "../../utils/logger.js";
|
||||
import { AuthManager } from "../../utils/auth-manager.js";
|
||||
import { UserCrypto } from "../../utils/user-crypto.js";
|
||||
import { DataCrypto } from "../../utils/data-crypto.js";
|
||||
|
||||
// Get auth manager instance
|
||||
const authManager = AuthManager.getInstance();
|
||||
|
||||
async function verifyOIDCToken(
|
||||
@@ -137,11 +134,8 @@ interface JWTPayload {
|
||||
exp?: number;
|
||||
}
|
||||
|
||||
// JWT authentication middleware - only verify JWT, no data unlock required
|
||||
const authenticateJWT = authManager.createAuthMiddleware();
|
||||
const requireAdmin = authManager.createAdminMiddleware();
|
||||
|
||||
// Data access middleware - requires user to have unlocked data keys
|
||||
const requireDataAccess = authManager.createDataAccessMiddleware();
|
||||
|
||||
// Route: Create traditional user (username/password)
|
||||
@@ -220,22 +214,20 @@ router.post("/create", async (req, res) => {
|
||||
totp_backup_codes: null,
|
||||
});
|
||||
|
||||
// Set up user data encryption (KEK-DEK architecture)
|
||||
try {
|
||||
await authManager.registerUser(id, password);
|
||||
authLogger.success("User encryption setup completed", {
|
||||
operation: "user_encryption_setup",
|
||||
userId: id,
|
||||
});
|
||||
} catch (encryptionError) {
|
||||
// If encryption setup fails, delete user record
|
||||
await db.delete(users).where(eq(users.id, id));
|
||||
authLogger.error("Failed to setup user encryption, user creation rolled back", encryptionError, {
|
||||
operation: "user_create_encryption_failed",
|
||||
userId: id,
|
||||
});
|
||||
authLogger.error(
|
||||
"Failed to setup user encryption, user creation rolled back",
|
||||
encryptionError,
|
||||
{
|
||||
operation: "user_create_encryption_failed",
|
||||
userId: id,
|
||||
},
|
||||
);
|
||||
return res.status(500).json({
|
||||
error: "Failed to setup user security - user creation cancelled"
|
||||
error: "Failed to setup user security - user creation cancelled",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -338,38 +330,46 @@ router.post("/oidc-config", authenticateJWT, async (req, res) => {
|
||||
scopes: scopes || "openid email profile",
|
||||
};
|
||||
|
||||
// Encrypt sensitive configuration for storage
|
||||
let encryptedConfig;
|
||||
try {
|
||||
// Use admin's data key to encrypt OIDC configuration
|
||||
const adminDataKey = DataCrypto.getUserDataKey(userId);
|
||||
if (adminDataKey) {
|
||||
// Provide stable recordId for settings objects
|
||||
const configWithId = { ...config, id: `oidc-config-${userId}` };
|
||||
encryptedConfig = DataCrypto.encryptRecord("settings", configWithId, userId, adminDataKey);
|
||||
encryptedConfig = DataCrypto.encryptRecord(
|
||||
"settings",
|
||||
configWithId,
|
||||
userId,
|
||||
adminDataKey,
|
||||
);
|
||||
authLogger.info("OIDC configuration encrypted with admin data key", {
|
||||
operation: "oidc_config_encrypt",
|
||||
userId,
|
||||
});
|
||||
} else {
|
||||
// If admin data not unlocked, only encrypt client_secret
|
||||
encryptedConfig = {
|
||||
...config,
|
||||
client_secret: `encrypted:${Buffer.from(client_secret).toString('base64')}`, // Simple base64 encoding
|
||||
client_secret: `encrypted:${Buffer.from(client_secret).toString("base64")}`, // Simple base64 encoding
|
||||
};
|
||||
authLogger.warn("OIDC configuration stored with basic encoding - admin should re-save with password", {
|
||||
operation: "oidc_config_basic_encoding",
|
||||
userId,
|
||||
});
|
||||
authLogger.warn(
|
||||
"OIDC configuration stored with basic encoding - admin should re-save with password",
|
||||
{
|
||||
operation: "oidc_config_basic_encoding",
|
||||
userId,
|
||||
},
|
||||
);
|
||||
}
|
||||
} catch (encryptError) {
|
||||
authLogger.error("Failed to encrypt OIDC configuration, storing with basic encoding", encryptError, {
|
||||
operation: "oidc_config_encrypt_failed",
|
||||
userId,
|
||||
});
|
||||
authLogger.error(
|
||||
"Failed to encrypt OIDC configuration, storing with basic encoding",
|
||||
encryptError,
|
||||
{
|
||||
operation: "oidc_config_encrypt_failed",
|
||||
userId,
|
||||
},
|
||||
);
|
||||
encryptedConfig = {
|
||||
...config,
|
||||
client_secret: `encoded:${Buffer.from(client_secret).toString('base64')}`,
|
||||
client_secret: `encoded:${Buffer.from(client_secret).toString("base64")}`,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -426,10 +426,8 @@ router.get("/oidc-config", async (req, res) => {
|
||||
|
||||
let config = JSON.parse((row as any).value);
|
||||
|
||||
// Decrypt or decode client_secret for display
|
||||
if (config.client_secret) {
|
||||
if (config.client_secret.startsWith('encrypted:')) {
|
||||
// Requires admin permission to decrypt
|
||||
if (config.client_secret.startsWith("encrypted:")) {
|
||||
const authHeader = req.headers["authorization"];
|
||||
if (authHeader?.startsWith("Bearer ")) {
|
||||
const token = authHeader.split(" ")[1];
|
||||
@@ -438,16 +436,22 @@ router.get("/oidc-config", async (req, res) => {
|
||||
|
||||
if (payload) {
|
||||
const userId = payload.userId;
|
||||
const user = await db.select().from(users).where(eq(users.id, userId));
|
||||
const user = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.id, userId));
|
||||
|
||||
if (user && user.length > 0 && user[0].is_admin) {
|
||||
try {
|
||||
const adminDataKey = DataCrypto.getUserDataKey(userId);
|
||||
if (adminDataKey) {
|
||||
// Use same stable recordId for decryption - note: FieldCrypto will use stored recordId
|
||||
config = DataCrypto.decryptRecord("settings", config, userId, adminDataKey);
|
||||
config = DataCrypto.decryptRecord(
|
||||
"settings",
|
||||
config,
|
||||
userId,
|
||||
adminDataKey,
|
||||
);
|
||||
} else {
|
||||
// Admin data not unlocked, hide client_secret
|
||||
config.client_secret = "[ENCRYPTED - PASSWORD REQUIRED]";
|
||||
}
|
||||
} catch (decryptError) {
|
||||
@@ -466,16 +470,17 @@ router.get("/oidc-config", async (req, res) => {
|
||||
} else {
|
||||
config.client_secret = "[ENCRYPTED - AUTH REQUIRED]";
|
||||
}
|
||||
} else if (config.client_secret.startsWith('encoded:')) {
|
||||
// base64 decode
|
||||
} else if (config.client_secret.startsWith("encoded:")) {
|
||||
try {
|
||||
const decoded = Buffer.from(config.client_secret.substring(8), 'base64').toString('utf8');
|
||||
const decoded = Buffer.from(
|
||||
config.client_secret.substring(8),
|
||||
"base64",
|
||||
).toString("utf8");
|
||||
config.client_secret = decoded;
|
||||
} catch {
|
||||
config.client_secret = "[ENCODING ERROR]";
|
||||
}
|
||||
}
|
||||
// Otherwise plaintext, return directly
|
||||
}
|
||||
|
||||
res.json(config);
|
||||
@@ -788,7 +793,11 @@ router.get("/oidc/callback", async (req, res) => {
|
||||
redirectUrl.searchParams.set("success", "true");
|
||||
|
||||
return res
|
||||
.cookie("jwt", token, authManager.getSecureCookieOptions(req, 50 * 24 * 60 * 60 * 1000))
|
||||
.cookie(
|
||||
"jwt",
|
||||
token,
|
||||
authManager.getSecureCookieOptions(req, 50 * 24 * 60 * 60 * 1000),
|
||||
)
|
||||
.redirect(redirectUrl.toString());
|
||||
} catch (err) {
|
||||
authLogger.error("OIDC callback failed", err);
|
||||
@@ -857,7 +866,6 @@ router.post("/login", async (req, res) => {
|
||||
return res.status(401).json({ error: "Incorrect password" });
|
||||
}
|
||||
|
||||
// Check if legacy user needs encryption setup
|
||||
try {
|
||||
const kekSalt = await db
|
||||
.select()
|
||||
@@ -865,13 +873,7 @@ router.post("/login", async (req, res) => {
|
||||
.where(eq(settings.key, `user_kek_salt_${userRecord.id}`));
|
||||
|
||||
if (kekSalt.length === 0) {
|
||||
// Legacy user first login - set up new encryption
|
||||
await authManager.registerUser(userRecord.id, password);
|
||||
authLogger.success("Legacy user encryption initialized", {
|
||||
operation: "legacy_user_setup",
|
||||
username,
|
||||
userId: userRecord.id,
|
||||
});
|
||||
}
|
||||
} catch (setupError) {
|
||||
authLogger.error("Failed to initialize user encryption", setupError, {
|
||||
@@ -879,11 +881,12 @@ router.post("/login", async (req, res) => {
|
||||
username,
|
||||
userId: userRecord.id,
|
||||
});
|
||||
// Encryption setup failure should not block login for existing users
|
||||
}
|
||||
|
||||
// Unlock user data keys
|
||||
const dataUnlocked = await authManager.authenticateUser(userRecord.id, password);
|
||||
const dataUnlocked = await authManager.authenticateUser(
|
||||
userRecord.id,
|
||||
password,
|
||||
);
|
||||
if (!dataUnlocked) {
|
||||
authLogger.error("Failed to unlock user data during login", undefined, {
|
||||
operation: "user_login_data_unlock_failed",
|
||||
@@ -891,11 +894,10 @@ router.post("/login", async (req, res) => {
|
||||
userId: userRecord.id,
|
||||
});
|
||||
return res.status(500).json({
|
||||
error: "Failed to unlock user data - please contact administrator"
|
||||
error: "Failed to unlock user data - please contact administrator",
|
||||
});
|
||||
}
|
||||
|
||||
// TOTP handling
|
||||
if (userRecord.totp_enabled) {
|
||||
const tempToken = await authManager.generateJWTToken(userRecord.id, {
|
||||
pendingTOTP: true,
|
||||
@@ -907,7 +909,6 @@ router.post("/login", async (req, res) => {
|
||||
});
|
||||
}
|
||||
|
||||
// Generate normal JWT token
|
||||
const token = await authManager.generateJWTToken(userRecord.id, {
|
||||
expiresIn: "24h",
|
||||
});
|
||||
@@ -920,7 +921,11 @@ router.post("/login", async (req, res) => {
|
||||
});
|
||||
|
||||
return res
|
||||
.cookie("jwt", token, authManager.getSecureCookieOptions(req, 24 * 60 * 60 * 1000))
|
||||
.cookie(
|
||||
"jwt",
|
||||
token,
|
||||
authManager.getSecureCookieOptions(req, 24 * 60 * 60 * 1000),
|
||||
)
|
||||
.json({
|
||||
success: true,
|
||||
is_admin: !!userRecord.is_admin,
|
||||
@@ -936,19 +941,16 @@ router.post("/login", async (req, res) => {
|
||||
// POST /users/logout
|
||||
router.post("/logout", async (req, res) => {
|
||||
try {
|
||||
// Try to get userId from JWT if available
|
||||
const userId = (req as any).userId;
|
||||
|
||||
|
||||
if (userId) {
|
||||
// User is authenticated - clear data session
|
||||
authManager.logoutUser(userId);
|
||||
authLogger.info("User logged out", {
|
||||
operation: "user_logout",
|
||||
userId,
|
||||
});
|
||||
}
|
||||
|
||||
// Always clear the JWT cookie
|
||||
|
||||
return res
|
||||
.clearCookie("jwt", authManager.getSecureCookieOptions(req))
|
||||
.json({ success: true, message: "Logged out successfully" });
|
||||
@@ -973,9 +975,8 @@ router.get("/me", authenticateJWT, async (req: Request, res: Response) => {
|
||||
return res.status(401).json({ error: "User not found" });
|
||||
}
|
||||
|
||||
// Check if user data is unlocked
|
||||
const isDataUnlocked = authManager.isUserUnlocked(userId);
|
||||
|
||||
|
||||
res.json({
|
||||
userId: user[0].id,
|
||||
username: user[0].username,
|
||||
@@ -1001,7 +1002,6 @@ router.get("/setup-required", async (req, res) => {
|
||||
|
||||
res.json({
|
||||
setup_required: count === 0,
|
||||
// 不暴露具体用户数量,只返回是否需要初始化
|
||||
});
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to check setup status", err);
|
||||
@@ -1014,7 +1014,6 @@ router.get("/setup-required", async (req, res) => {
|
||||
router.get("/count", authenticateJWT, async (req, res) => {
|
||||
const userId = (req as any).userId;
|
||||
try {
|
||||
// 只有管理员可以查看用户统计
|
||||
const user = await db.select().from(users).where(eq(users.id, userId));
|
||||
if (!user[0] || !user[0].is_admin) {
|
||||
return res.status(403).json({ error: "Admin access required" });
|
||||
@@ -1282,14 +1281,15 @@ router.post("/complete-reset", async (req, res) => {
|
||||
return res.status(400).json({ error: "Invalid temporary token" });
|
||||
}
|
||||
|
||||
// Get user ID for KEK-DEK operations
|
||||
const user = await db.select().from(users).where(eq(users.username, username));
|
||||
const user = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.username, username));
|
||||
if (!user || user.length === 0) {
|
||||
return res.status(404).json({ error: "User not found" });
|
||||
}
|
||||
const userId = user[0].id;
|
||||
|
||||
// Update password hash
|
||||
const saltRounds = parseInt(process.env.SALT || "10", 10);
|
||||
const password_hash = await bcrypt.hash(newPassword, saltRounds);
|
||||
|
||||
@@ -1490,7 +1490,11 @@ router.post("/totp/verify-login", async (req, res) => {
|
||||
});
|
||||
|
||||
return res
|
||||
.cookie("jwt", token, authManager.getSecureCookieOptions(req, 50 * 24 * 60 * 60 * 1000))
|
||||
.cookie(
|
||||
"jwt",
|
||||
token,
|
||||
authManager.getSecureCookieOptions(req, 50 * 24 * 60 * 60 * 1000),
|
||||
)
|
||||
.json({
|
||||
success: true,
|
||||
is_admin: !!userRecord.is_admin,
|
||||
@@ -1802,8 +1806,6 @@ router.delete("/delete-user", authenticateJWT, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// ===== New security API endpoints =====
|
||||
|
||||
// Route: User data unlock - used when session expires
|
||||
// POST /users/unlock-data
|
||||
router.post("/unlock-data", authenticateJWT, async (req, res) => {
|
||||
@@ -1817,13 +1819,9 @@ router.post("/unlock-data", authenticateJWT, async (req, res) => {
|
||||
try {
|
||||
const unlocked = await authManager.authenticateUser(userId, password);
|
||||
if (unlocked) {
|
||||
authLogger.success("User data unlocked", {
|
||||
operation: "user_data_unlock",
|
||||
userId,
|
||||
});
|
||||
res.json({
|
||||
success: true,
|
||||
message: "Data unlocked successfully"
|
||||
message: "Data unlocked successfully",
|
||||
});
|
||||
} else {
|
||||
authLogger.warn("Failed to unlock user data - invalid password", {
|
||||
@@ -1845,12 +1843,14 @@ router.post("/unlock-data", authenticateJWT, async (req, res) => {
|
||||
// GET /users/data-status
|
||||
router.get("/data-status", authenticateJWT, async (req, res) => {
|
||||
const userId = (req as any).userId;
|
||||
|
||||
|
||||
try {
|
||||
const isUnlocked = authManager.isUserUnlocked(userId);
|
||||
res.json({
|
||||
unlocked: isUnlocked,
|
||||
message: isUnlocked ? "Data is unlocked" : "Data is locked - re-authenticate with password"
|
||||
message: isUnlocked
|
||||
? "Data is unlocked"
|
||||
: "Data is locked - re-authenticate with password",
|
||||
});
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to check data status", err, {
|
||||
@@ -1869,26 +1869,24 @@ router.post("/change-password", authenticateJWT, async (req, res) => {
|
||||
|
||||
if (!currentPassword || !newPassword) {
|
||||
return res.status(400).json({
|
||||
error: "Current password and new password are required"
|
||||
error: "Current password and new password are required",
|
||||
});
|
||||
}
|
||||
|
||||
if (newPassword.length < 8) {
|
||||
return res.status(400).json({
|
||||
error: "New password must be at least 8 characters long"
|
||||
error: "New password must be at least 8 characters long",
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
// Verify current password and change
|
||||
const success = await authManager.changeUserPassword(
|
||||
userId,
|
||||
currentPassword,
|
||||
newPassword
|
||||
newPassword,
|
||||
);
|
||||
|
||||
if (success) {
|
||||
// Also update password hash in database
|
||||
const saltRounds = parseInt(process.env.SALT || "10", 10);
|
||||
const newPasswordHash = await bcrypt.hash(newPassword, saltRounds);
|
||||
await db
|
||||
@@ -1903,7 +1901,7 @@ router.post("/change-password", authenticateJWT, async (req, res) => {
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: "Password changed successfully"
|
||||
message: "Password changed successfully",
|
||||
});
|
||||
} else {
|
||||
authLogger.warn("Password change failed - invalid current password", {
|
||||
@@ -1921,4 +1919,4 @@ router.post("/change-password", authenticateJWT, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
export default router;
|
||||
|
||||
@@ -9,13 +9,10 @@ import { fileLogger } from "../utils/logger.js";
|
||||
import { SimpleDBOps } from "../utils/simple-db-ops.js";
|
||||
import { AuthManager } from "../utils/auth-manager.js";
|
||||
|
||||
// Executable file detection utility function
|
||||
function isExecutableFile(permissions: string, fileName: string): boolean {
|
||||
// Check execute permission bits (user, group, other)
|
||||
const hasExecutePermission =
|
||||
permissions[3] === "x" || permissions[6] === "x" || permissions[9] === "x";
|
||||
|
||||
// Common script file extensions
|
||||
const scriptExtensions = [
|
||||
".sh",
|
||||
".py",
|
||||
@@ -31,13 +28,11 @@ function isExecutableFile(permissions: string, fileName: string): boolean {
|
||||
fileName.toLowerCase().endsWith(ext),
|
||||
);
|
||||
|
||||
// Common compiled executable files (no extension or specific extensions)
|
||||
const executableExtensions = [".bin", ".exe", ".out"];
|
||||
const hasExecutableExtension = executableExtensions.some((ext) =>
|
||||
fileName.toLowerCase().endsWith(ext),
|
||||
);
|
||||
|
||||
// Files with no extension and execute permission are usually executable files
|
||||
const hasNoExtension = !fileName.includes(".") && hasExecutePermission;
|
||||
|
||||
return (
|
||||
@@ -51,33 +46,27 @@ const app = express();
|
||||
app.use(
|
||||
cors({
|
||||
origin: (origin, callback) => {
|
||||
// Allow requests with no origin (like mobile apps or curl requests)
|
||||
if (!origin) return callback(null, true);
|
||||
|
||||
// Allow localhost and 127.0.0.1 for development
|
||||
|
||||
const allowedOrigins = [
|
||||
"http://localhost:5173",
|
||||
"http://localhost:3000",
|
||||
"http://localhost:3000",
|
||||
"http://127.0.0.1:5173",
|
||||
"http://127.0.0.1:3000"
|
||||
"http://127.0.0.1:3000",
|
||||
];
|
||||
|
||||
// Allow any HTTPS origin (production deployments)
|
||||
|
||||
if (origin.startsWith("https://")) {
|
||||
return callback(null, true);
|
||||
}
|
||||
|
||||
// Allow any HTTP origin for self-hosted scenarios
|
||||
|
||||
if (origin.startsWith("http://")) {
|
||||
return callback(null, true);
|
||||
}
|
||||
|
||||
// Check against allowed development origins
|
||||
|
||||
if (allowedOrigins.includes(origin)) {
|
||||
return callback(null, true);
|
||||
}
|
||||
|
||||
// Reject other origins
|
||||
|
||||
callback(new Error("Not allowed by CORS"));
|
||||
},
|
||||
credentials: true,
|
||||
@@ -95,7 +84,6 @@ app.use(express.json({ limit: "1gb" }));
|
||||
app.use(express.urlencoded({ limit: "1gb", extended: true }));
|
||||
app.use(express.raw({ limit: "5gb", type: "application/octet-stream" }));
|
||||
|
||||
// Initialize AuthManager and add authentication middleware
|
||||
const authManager = AuthManager.getInstance();
|
||||
app.use(authManager.createAuthMiddleware());
|
||||
|
||||
@@ -122,16 +110,37 @@ function cleanupSession(sessionId: string) {
|
||||
function scheduleSessionCleanup(sessionId: string) {
|
||||
const session = sshSessions[sessionId];
|
||||
if (session) {
|
||||
// Clear existing timeout
|
||||
if (session.timeout) clearTimeout(session.timeout);
|
||||
|
||||
// Increase timeout to 30 minutes of inactivity
|
||||
session.timeout = setTimeout(() => {
|
||||
cleanupSession(sessionId);
|
||||
}, 30 * 60 * 1000); // 30 minutes - increased from 10 minutes
|
||||
session.timeout = setTimeout(
|
||||
() => {
|
||||
cleanupSession(sessionId);
|
||||
},
|
||||
30 * 60 * 1000,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function getMimeType(fileName: string): string {
|
||||
const ext = fileName.split(".").pop()?.toLowerCase();
|
||||
const mimeTypes: Record<string, string> = {
|
||||
txt: "text/plain",
|
||||
json: "application/json",
|
||||
js: "text/javascript",
|
||||
html: "text/html",
|
||||
css: "text/css",
|
||||
png: "image/png",
|
||||
jpg: "image/jpeg",
|
||||
jpeg: "image/jpeg",
|
||||
gif: "image/gif",
|
||||
pdf: "application/pdf",
|
||||
zip: "application/zip",
|
||||
tar: "application/x-tar",
|
||||
gz: "application/gzip",
|
||||
};
|
||||
return mimeTypes[ext || ""] || "application/octet-stream";
|
||||
}
|
||||
|
||||
app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
|
||||
const {
|
||||
sessionId,
|
||||
@@ -146,7 +155,6 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
|
||||
credentialId,
|
||||
} = req.body;
|
||||
|
||||
// Use authenticated user ID from middleware
|
||||
const userId = (req as any).userId;
|
||||
|
||||
if (!userId) {
|
||||
@@ -194,7 +202,7 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
|
||||
const credential = credentials[0];
|
||||
resolvedCredentials = {
|
||||
password: credential.password,
|
||||
sshKey: credential.privateKey || credential.key, // prefer new privateKey field
|
||||
sshKey: credential.privateKey || credential.key,
|
||||
keyPassword: credential.keyPassword,
|
||||
authType: credential.authType,
|
||||
};
|
||||
@@ -255,7 +263,14 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
|
||||
"aes256-cbc",
|
||||
"3des-cbc",
|
||||
],
|
||||
hmac: ["hmac-sha2-256-etm@openssh.com", "hmac-sha2-512-etm@openssh.com", "hmac-sha2-256", "hmac-sha2-512", "hmac-sha1", "hmac-md5"],
|
||||
hmac: [
|
||||
"hmac-sha2-256-etm@openssh.com",
|
||||
"hmac-sha2-512-etm@openssh.com",
|
||||
"hmac-sha2-256",
|
||||
"hmac-sha2-512",
|
||||
"hmac-sha1",
|
||||
"hmac-md5",
|
||||
],
|
||||
compress: ["none", "zlib@openssh.com", "zlib"],
|
||||
},
|
||||
};
|
||||
@@ -352,7 +367,6 @@ app.get("/ssh/file_manager/ssh/status", (req, res) => {
|
||||
res.json({ status: "success", connected: isConnected });
|
||||
});
|
||||
|
||||
// SSH keepalive endpoint - extends session timeout and verifies connection
|
||||
app.post("/ssh/file_manager/ssh/keepalive", (req, res) => {
|
||||
const { sessionId } = req.body;
|
||||
|
||||
@@ -365,11 +379,10 @@ app.post("/ssh/file_manager/ssh/keepalive", (req, res) => {
|
||||
if (!session || !session.isConnected) {
|
||||
return res.status(400).json({
|
||||
error: "SSH session not found or not connected",
|
||||
connected: false
|
||||
connected: false,
|
||||
});
|
||||
}
|
||||
|
||||
// Update last active time and reschedule cleanup
|
||||
session.lastActive = Date.now();
|
||||
scheduleSessionCleanup(sessionId);
|
||||
|
||||
@@ -377,7 +390,7 @@ app.post("/ssh/file_manager/ssh/keepalive", (req, res) => {
|
||||
status: "success",
|
||||
connected: true,
|
||||
message: "Session keepalive successful",
|
||||
lastActive: session.lastActive
|
||||
lastActive: session.lastActive,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -435,12 +448,10 @@ app.get("/ssh/file_manager/ssh/listFiles", (req, res) => {
|
||||
const group = parts[3];
|
||||
const size = parseInt(parts[4], 10);
|
||||
|
||||
// Date may occupy 3 parts (month day time) or (month day year)
|
||||
let dateStr = "";
|
||||
let nameStartIndex = 8;
|
||||
|
||||
if (parts[5] && parts[6] && parts[7]) {
|
||||
// Regular format: month day time/year
|
||||
dateStr = `${parts[5]} ${parts[6]} ${parts[7]}`;
|
||||
}
|
||||
|
||||
@@ -450,7 +461,6 @@ app.get("/ssh/file_manager/ssh/listFiles", (req, res) => {
|
||||
|
||||
if (name === "." || name === "..") continue;
|
||||
|
||||
// Parse symbolic link target
|
||||
let actualName = name;
|
||||
let linkTarget = undefined;
|
||||
if (isLink && name.includes(" -> ")) {
|
||||
@@ -462,17 +472,17 @@ app.get("/ssh/file_manager/ssh/listFiles", (req, res) => {
|
||||
files.push({
|
||||
name: actualName,
|
||||
type: isDirectory ? "directory" : isLink ? "link" : "file",
|
||||
size: isDirectory ? undefined : size, // Directories don't show size
|
||||
size: isDirectory ? undefined : size,
|
||||
modified: dateStr,
|
||||
permissions,
|
||||
owner,
|
||||
group,
|
||||
linkTarget, // Symbolic link target
|
||||
path: `${sshPath.endsWith("/") ? sshPath : sshPath + "/"}${actualName}`, // Add full path
|
||||
linkTarget,
|
||||
path: `${sshPath.endsWith("/") ? sshPath : sshPath + "/"}${actualName}`,
|
||||
executable:
|
||||
!isDirectory && !isLink
|
||||
? isExecutableFile(permissions, actualName)
|
||||
: false, // Detect executable files
|
||||
: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -568,11 +578,9 @@ app.get("/ssh/file_manager/ssh/readFile", (req, res) => {
|
||||
|
||||
sshConn.lastActive = Date.now();
|
||||
|
||||
// Support large file reading - increased limit for better compatibility
|
||||
const MAX_READ_SIZE = 500 * 1024 * 1024; // 500MB - much more reasonable limit
|
||||
const MAX_READ_SIZE = 500 * 1024 * 1024;
|
||||
const escapedPath = filePath.replace(/'/g, "'\"'\"'");
|
||||
|
||||
// Get file size first
|
||||
sshConn.client.exec(
|
||||
`stat -c%s '${escapedPath}' 2>/dev/null || wc -c < '${escapedPath}'`,
|
||||
(sizeErr, sizeStream) => {
|
||||
@@ -594,20 +602,18 @@ app.get("/ssh/file_manager/ssh/readFile", (req, res) => {
|
||||
|
||||
sizeStream.on("close", (sizeCode) => {
|
||||
if (sizeCode !== 0) {
|
||||
// Check if it's a file not found error (case-insensitive)
|
||||
const errorLower = sizeErrorData.toLowerCase();
|
||||
const isFileNotFound = errorLower.includes("no such file or directory") ||
|
||||
errorLower.includes("cannot access") ||
|
||||
errorLower.includes("not found") ||
|
||||
errorLower.includes("resource not found");
|
||||
const isFileNotFound =
|
||||
errorLower.includes("no such file or directory") ||
|
||||
errorLower.includes("cannot access") ||
|
||||
errorLower.includes("not found") ||
|
||||
errorLower.includes("resource not found");
|
||||
|
||||
fileLogger.error(`File size check failed: ${sizeErrorData}`);
|
||||
return res
|
||||
.status(isFileNotFound ? 404 : 500)
|
||||
.json({
|
||||
error: `Cannot check file size: ${sizeErrorData}`,
|
||||
fileNotFound: isFileNotFound
|
||||
});
|
||||
return res.status(isFileNotFound ? 404 : 500).json({
|
||||
error: `Cannot check file size: ${sizeErrorData}`,
|
||||
fileNotFound: isFileNotFound,
|
||||
});
|
||||
}
|
||||
|
||||
const fileSize = parseInt(sizeData.trim(), 10);
|
||||
@@ -617,7 +623,6 @@ app.get("/ssh/file_manager/ssh/readFile", (req, res) => {
|
||||
return res.status(500).json({ error: "Cannot determine file size" });
|
||||
}
|
||||
|
||||
// Check if file is too large
|
||||
if (fileSize > MAX_READ_SIZE) {
|
||||
fileLogger.warn("File too large for reading", {
|
||||
operation: "file_read",
|
||||
@@ -634,7 +639,6 @@ app.get("/ssh/file_manager/ssh/readFile", (req, res) => {
|
||||
});
|
||||
}
|
||||
|
||||
// File size is acceptable, proceed with reading
|
||||
sshConn.client.exec(`cat '${escapedPath}'`, (err, stream) => {
|
||||
if (err) {
|
||||
fileLogger.error("SSH readFile error:", err);
|
||||
@@ -658,18 +662,15 @@ app.get("/ssh/file_manager/ssh/readFile", (req, res) => {
|
||||
`SSH readFile command failed with code ${code}: ${errorData.replace(/\n/g, " ").trim()}`,
|
||||
);
|
||||
|
||||
// Check if it's a "file not found" error
|
||||
const isFileNotFound =
|
||||
errorData.includes("No such file or directory") ||
|
||||
errorData.includes("cannot access") ||
|
||||
errorData.includes("not found");
|
||||
|
||||
return res
|
||||
.status(isFileNotFound ? 404 : 500)
|
||||
.json({
|
||||
error: `Command failed: ${errorData}`,
|
||||
fileNotFound: isFileNotFound
|
||||
});
|
||||
return res.status(isFileNotFound ? 404 : 500).json({
|
||||
error: `Command failed: ${errorData}`,
|
||||
fileNotFound: isFileNotFound,
|
||||
});
|
||||
}
|
||||
|
||||
res.json({ content: data, path: filePath });
|
||||
@@ -892,21 +893,12 @@ app.post("/ssh/file_manager/ssh/uploadFile", async (req, res) => {
|
||||
.json({ error: "File path, name, and content are required" });
|
||||
}
|
||||
|
||||
// Update last active time and extend keepalive for large file operations
|
||||
sshConn.lastActive = Date.now();
|
||||
|
||||
// For large files, extend the keepalive interval to prevent connection drops
|
||||
const contentSize = typeof content === 'string' ? Buffer.byteLength(content, 'utf8') : content.length;
|
||||
if (contentSize > 10 * 1024 * 1024) { // 10MB threshold
|
||||
fileLogger.info("Large file upload detected, extending SSH keepalive", {
|
||||
operation: "file_upload",
|
||||
sessionId,
|
||||
fileName,
|
||||
fileSize: contentSize,
|
||||
});
|
||||
// Note: SSH2 handles keepalive through connection options (keepaliveInterval, keepaliveCountMax)
|
||||
// which are set during connection establishment. No runtime method is available.
|
||||
}
|
||||
|
||||
const contentSize =
|
||||
typeof content === "string"
|
||||
? Buffer.byteLength(content, "utf8")
|
||||
: content.length;
|
||||
|
||||
const fullPath = filePath.endsWith("/")
|
||||
? filePath + fileName
|
||||
@@ -958,7 +950,7 @@ app.post("/ssh/file_manager/ssh/uploadFile", async (req, res) => {
|
||||
fileName,
|
||||
fileSize: contentSize,
|
||||
error: streamErr.message,
|
||||
}
|
||||
},
|
||||
);
|
||||
tryFallbackMethod();
|
||||
});
|
||||
@@ -1591,7 +1583,6 @@ app.put("/ssh/file_manager/ssh/renameItem", async (req, res) => {
|
||||
});
|
||||
});
|
||||
|
||||
// New API for moving files/folders across directories (for cut operation)
|
||||
app.put("/ssh/file_manager/ssh/moveItem", async (req, res) => {
|
||||
const { sessionId, oldPath, newPath, hostId, userId } = req.body;
|
||||
const sshConn = sshSessions[sessionId];
|
||||
@@ -1617,7 +1608,6 @@ app.put("/ssh/file_manager/ssh/moveItem", async (req, res) => {
|
||||
|
||||
const moveCommand = `mv '${escapedOldPath}' '${escapedNewPath}' && echo "SUCCESS" && exit 0`;
|
||||
|
||||
// Add timeout for move operation
|
||||
const commandTimeout = setTimeout(() => {
|
||||
if (!res.headersSent) {
|
||||
res.status(408).json({
|
||||
@@ -1628,7 +1618,7 @@ app.put("/ssh/file_manager/ssh/moveItem", async (req, res) => {
|
||||
},
|
||||
});
|
||||
}
|
||||
}, 60000); // 60 second timeout for move operations
|
||||
}, 60000);
|
||||
|
||||
sshConn.client.exec(moveCommand, (err, stream) => {
|
||||
if (err) {
|
||||
@@ -1745,14 +1735,12 @@ app.post("/ssh/file_manager/ssh/downloadFile", async (req, res) => {
|
||||
sshConn.lastActive = Date.now();
|
||||
scheduleSessionCleanup(sessionId);
|
||||
|
||||
// Use SFTP to read file for binary safety
|
||||
sshConn.client.sftp((err, sftp) => {
|
||||
if (err) {
|
||||
fileLogger.error("SFTP connection failed for download:", err);
|
||||
return res.status(500).json({ error: "SFTP connection failed" });
|
||||
}
|
||||
|
||||
// Get file stats first to check if it's a regular file and get size
|
||||
sftp.stat(filePath, (statErr, stats) => {
|
||||
if (statErr) {
|
||||
fileLogger.error("File stat failed for download:", statErr);
|
||||
@@ -1774,8 +1762,7 @@ app.post("/ssh/file_manager/ssh/downloadFile", async (req, res) => {
|
||||
.json({ error: "Cannot download directories or special files" });
|
||||
}
|
||||
|
||||
// Support large file downloads - increased limit for better compatibility
|
||||
const MAX_FILE_SIZE = 5 * 1024 * 1024 * 1024; // 5GB - reasonable for SSH file operations
|
||||
const MAX_FILE_SIZE = 5 * 1024 * 1024 * 1024;
|
||||
if (stats.size > MAX_FILE_SIZE) {
|
||||
fileLogger.warn("File too large for download", {
|
||||
operation: "file_download",
|
||||
@@ -1789,7 +1776,6 @@ app.post("/ssh/file_manager/ssh/downloadFile", async (req, res) => {
|
||||
});
|
||||
}
|
||||
|
||||
// Read file content
|
||||
sftp.readFile(filePath, (readErr, data) => {
|
||||
if (readErr) {
|
||||
fileLogger.error("File read failed for download:", readErr);
|
||||
@@ -1798,7 +1784,6 @@ app.post("/ssh/file_manager/ssh/downloadFile", async (req, res) => {
|
||||
.json({ error: `Failed to read file: ${readErr.message}` });
|
||||
}
|
||||
|
||||
// Convert to base64 for safe transport
|
||||
const base64Content = data.toString("base64");
|
||||
const fileName = filePath.split("/").pop() || "download";
|
||||
|
||||
@@ -1824,7 +1809,6 @@ app.post("/ssh/file_manager/ssh/downloadFile", async (req, res) => {
|
||||
});
|
||||
});
|
||||
|
||||
// Copy SSH file/directory
|
||||
app.post("/ssh/file_manager/ssh/copyItem", async (req, res) => {
|
||||
const { sessionId, sourcePath, targetDir, hostId, userId } = req.body;
|
||||
|
||||
@@ -1842,96 +1826,57 @@ app.post("/ssh/file_manager/ssh/copyItem", async (req, res) => {
|
||||
sshConn.lastActive = Date.now();
|
||||
scheduleSessionCleanup(sessionId);
|
||||
|
||||
try {
|
||||
// Extract source name
|
||||
const sourceName = sourcePath.split("/").pop() || "copied_item";
|
||||
const sourceName = sourcePath.split("/").pop() || "copied_item";
|
||||
|
||||
// Linus principle: simplify - generate unique name directly without complex checks
|
||||
const timestamp = Date.now().toString().slice(-8);
|
||||
const uniqueName = `${sourceName}_copy_${timestamp}`;
|
||||
const targetPath = `${targetDir}/${uniqueName}`;
|
||||
const timestamp = Date.now().toString().slice(-8);
|
||||
const uniqueName = `${sourceName}_copy_${timestamp}`;
|
||||
const targetPath = `${targetDir}/${uniqueName}`;
|
||||
|
||||
fileLogger.info("Starting copy operation", {
|
||||
originalName: sourceName,
|
||||
uniqueName,
|
||||
const escapedSource = sourcePath.replace(/'/g, "'\"'\"'");
|
||||
const escapedTarget = targetPath.replace(/'/g, "'\"'\"'");
|
||||
|
||||
const copyCommand = `cp '${escapedSource}' '${escapedTarget}' && echo "COPY_SUCCESS"`;
|
||||
|
||||
const commandTimeout = setTimeout(() => {
|
||||
fileLogger.error("Copy command timed out after 60 seconds", {
|
||||
sourcePath,
|
||||
targetPath,
|
||||
sessionId,
|
||||
command: copyCommand,
|
||||
});
|
||||
|
||||
// Escape paths for shell commands
|
||||
const escapedSource = sourcePath.replace(/'/g, "'\"'\"'");
|
||||
const escapedTarget = targetPath.replace(/'/g, "'\"'\"'");
|
||||
|
||||
// Linus principle: simplify - use basic cp command for reliability
|
||||
// Just copy the file without complex flags that might cause issues
|
||||
const copyCommand = `cp '${escapedSource}' '${escapedTarget}' && echo "COPY_SUCCESS"`;
|
||||
|
||||
fileLogger.info("Starting file copy operation", {
|
||||
operation: "file_copy_start",
|
||||
sessionId,
|
||||
sourcePath,
|
||||
targetPath,
|
||||
uniqueName,
|
||||
command: copyCommand.substring(0, 200) + "...", // Log truncated command
|
||||
});
|
||||
|
||||
// Add timeout to prevent hanging
|
||||
const commandTimeout = setTimeout(() => {
|
||||
fileLogger.error("Copy command timed out after 60 seconds", {
|
||||
sourcePath,
|
||||
targetPath,
|
||||
command: copyCommand,
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({
|
||||
error: "Copy operation timed out",
|
||||
toast: {
|
||||
type: "error",
|
||||
message: "Copy operation timed out. SSH connection may be unstable.",
|
||||
},
|
||||
});
|
||||
}
|
||||
}, 60000);
|
||||
|
||||
sshConn.client.exec(copyCommand, (err, stream) => {
|
||||
if (err) {
|
||||
clearTimeout(commandTimeout);
|
||||
fileLogger.error("SSH copyItem error:", err);
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({
|
||||
error: "Copy operation timed out",
|
||||
toast: {
|
||||
type: "error",
|
||||
message:
|
||||
"Copy operation timed out. SSH connection may be unstable.",
|
||||
},
|
||||
});
|
||||
return res.status(500).json({ error: err.message });
|
||||
}
|
||||
}, 60000); // 60 second timeout for large files
|
||||
return;
|
||||
}
|
||||
|
||||
sshConn.client.exec(copyCommand, (err, stream) => {
|
||||
if (err) {
|
||||
clearTimeout(commandTimeout);
|
||||
fileLogger.error("SSH copyItem error:", err);
|
||||
if (!res.headersSent) {
|
||||
return res.status(500).json({ error: err.message });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let errorData = "";
|
||||
let stdoutData = "";
|
||||
|
||||
// Monitor both stdout and stderr
|
||||
stream.on("data", (data: Buffer) => {
|
||||
const output = data.toString();
|
||||
stdoutData += output;
|
||||
fileLogger.info("Copy command stdout", {
|
||||
output: output.substring(0, 200),
|
||||
});
|
||||
});
|
||||
let errorData = "";
|
||||
let stdoutData = "";
|
||||
|
||||
stream.on("data", (data: Buffer) => {
|
||||
const output = data.toString();
|
||||
stdoutData += output;
|
||||
stream.stderr.on("data", (data: Buffer) => {
|
||||
const output = data.toString();
|
||||
errorData += output;
|
||||
fileLogger.info("Copy command stderr", {
|
||||
output: output.substring(0, 200),
|
||||
});
|
||||
});
|
||||
|
||||
stream.on("close", (code) => {
|
||||
clearTimeout(commandTimeout);
|
||||
fileLogger.info("Copy command completed", {
|
||||
code,
|
||||
errorData,
|
||||
hasError: errorData.length > 0,
|
||||
});
|
||||
|
||||
if (code !== 0) {
|
||||
const fullErrorInfo =
|
||||
@@ -1965,8 +1910,8 @@ app.post("/ssh/file_manager/ssh/copyItem", async (req, res) => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify copy completion with COPY_SUCCESS marker or exit code 0
|
||||
const copySuccessful = stdoutData.includes("COPY_SUCCESS") || code === 0;
|
||||
const copySuccessful =
|
||||
stdoutData.includes("COPY_SUCCESS") || code === 0;
|
||||
|
||||
if (copySuccessful) {
|
||||
fileLogger.success("Item copied successfully", {
|
||||
@@ -2024,168 +1969,124 @@ app.post("/ssh/file_manager/ssh/copyItem", async (req, res) => {
|
||||
}
|
||||
});
|
||||
});
|
||||
} catch (error: any) {
|
||||
fileLogger.error("Copy operation error:", error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Helper function to determine MIME type based on file extension
|
||||
function getMimeType(fileName: string): string {
|
||||
const ext = fileName.split(".").pop()?.toLowerCase();
|
||||
const mimeTypes: Record<string, string> = {
|
||||
txt: "text/plain",
|
||||
json: "application/json",
|
||||
js: "text/javascript",
|
||||
html: "text/html",
|
||||
css: "text/css",
|
||||
png: "image/png",
|
||||
jpg: "image/jpeg",
|
||||
jpeg: "image/jpeg",
|
||||
gif: "image/gif",
|
||||
pdf: "application/pdf",
|
||||
zip: "application/zip",
|
||||
tar: "application/x-tar",
|
||||
gz: "application/gzip",
|
||||
};
|
||||
return mimeTypes[ext || ""] || "application/octet-stream";
|
||||
}
|
||||
process.on("SIGINT", () => {
|
||||
Object.keys(sshSessions).forEach(cleanupSession);
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
process.on("SIGINT", () => {
|
||||
Object.keys(sshSessions).forEach(cleanupSession);
|
||||
process.exit(0);
|
||||
});
|
||||
process.on("SIGTERM", () => {
|
||||
Object.keys(sshSessions).forEach(cleanupSession);
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
process.on("SIGTERM", () => {
|
||||
Object.keys(sshSessions).forEach(cleanupSession);
|
||||
process.exit(0);
|
||||
});
|
||||
app.post("/ssh/file_manager/ssh/executeFile", async (req, res) => {
|
||||
const { sessionId, filePath, hostId, userId } = req.body;
|
||||
const sshConn = sshSessions[sessionId];
|
||||
|
||||
// Execute executable file
|
||||
app.post("/ssh/file_manager/ssh/executeFile", async (req, res) => {
|
||||
const { sessionId, filePath, hostId, userId } = req.body;
|
||||
const sshConn = sshSessions[sessionId];
|
||||
|
||||
if (!sshConn || !sshConn.isConnected) {
|
||||
fileLogger.error(
|
||||
"SSH connection not found or not connected for executeFile",
|
||||
{
|
||||
operation: "execute_file",
|
||||
sessionId,
|
||||
hasConnection: !!sshConn,
|
||||
isConnected: sshConn?.isConnected,
|
||||
},
|
||||
);
|
||||
return res.status(400).json({ error: "SSH connection not available" });
|
||||
}
|
||||
|
||||
if (!filePath) {
|
||||
return res.status(400).json({ error: "File path is required" });
|
||||
}
|
||||
|
||||
const escapedPath = filePath.replace(/'/g, "'\"'\"'");
|
||||
|
||||
// Check if file exists and is executable
|
||||
const checkCommand = `test -x '${escapedPath}' && echo "EXECUTABLE" || echo "NOT_EXECUTABLE"`;
|
||||
|
||||
sshConn.client.exec(checkCommand, (checkErr, checkStream) => {
|
||||
if (checkErr) {
|
||||
fileLogger.error("SSH executeFile check error:", checkErr);
|
||||
return res
|
||||
.status(500)
|
||||
.json({ error: "Failed to check file executability" });
|
||||
if (!sshConn || !sshConn.isConnected) {
|
||||
fileLogger.error(
|
||||
"SSH connection not found or not connected for executeFile",
|
||||
{
|
||||
operation: "execute_file",
|
||||
sessionId,
|
||||
hasConnection: !!sshConn,
|
||||
isConnected: sshConn?.isConnected,
|
||||
},
|
||||
);
|
||||
return res.status(400).json({ error: "SSH connection not available" });
|
||||
}
|
||||
|
||||
let checkResult = "";
|
||||
checkStream.on("data", (data) => {
|
||||
checkResult += data.toString();
|
||||
});
|
||||
if (!filePath) {
|
||||
return res.status(400).json({ error: "File path is required" });
|
||||
}
|
||||
|
||||
checkStream.on("close", (code) => {
|
||||
if (!checkResult.includes("EXECUTABLE")) {
|
||||
return res.status(400).json({ error: "File is not executable" });
|
||||
const escapedPath = filePath.replace(/'/g, "'\"'\"'");
|
||||
|
||||
const checkCommand = `test -x '${escapedPath}' && echo "EXECUTABLE" || echo "NOT_EXECUTABLE"`;
|
||||
|
||||
sshConn.client.exec(checkCommand, (checkErr, checkStream) => {
|
||||
if (checkErr) {
|
||||
fileLogger.error("SSH executeFile check error:", checkErr);
|
||||
return res
|
||||
.status(500)
|
||||
.json({ error: "Failed to check file executability" });
|
||||
}
|
||||
|
||||
// Execute file
|
||||
const executeCommand = `cd "$(dirname '${escapedPath}')" && '${escapedPath}' 2>&1; echo "EXIT_CODE:$?"`;
|
||||
|
||||
fileLogger.info("Executing file", {
|
||||
operation: "execute_file",
|
||||
sessionId,
|
||||
filePath,
|
||||
command: executeCommand.substring(0, 100) + "...",
|
||||
let checkResult = "";
|
||||
checkStream.on("data", (data) => {
|
||||
checkResult += data.toString();
|
||||
});
|
||||
|
||||
sshConn.client.exec(executeCommand, (err, stream) => {
|
||||
if (err) {
|
||||
fileLogger.error("SSH executeFile error:", err);
|
||||
return res.status(500).json({ error: "Failed to execute file" });
|
||||
checkStream.on("close", (code) => {
|
||||
if (!checkResult.includes("EXECUTABLE")) {
|
||||
return res.status(400).json({ error: "File is not executable" });
|
||||
}
|
||||
|
||||
let output = "";
|
||||
let errorOutput = "";
|
||||
const executeCommand = `cd "$(dirname '${escapedPath}')" && '${escapedPath}' 2>&1; echo "EXIT_CODE:$?"`;
|
||||
|
||||
stream.on("data", (data) => {
|
||||
output += data.toString();
|
||||
});
|
||||
|
||||
stream.stderr.on("data", (data) => {
|
||||
errorOutput += data.toString();
|
||||
});
|
||||
|
||||
stream.on("close", (code) => {
|
||||
// Extract exit code from output
|
||||
const exitCodeMatch = output.match(/EXIT_CODE:(\d+)$/);
|
||||
const actualExitCode = exitCodeMatch
|
||||
? parseInt(exitCodeMatch[1])
|
||||
: code;
|
||||
const cleanOutput = output.replace(/EXIT_CODE:\d+$/, "").trim();
|
||||
|
||||
fileLogger.info("File execution completed", {
|
||||
operation: "execute_file",
|
||||
sessionId,
|
||||
filePath,
|
||||
exitCode: actualExitCode,
|
||||
outputLength: cleanOutput.length,
|
||||
errorLength: errorOutput.length,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
exitCode: actualExitCode,
|
||||
output: cleanOutput,
|
||||
error: errorOutput,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
});
|
||||
|
||||
stream.on("error", (streamErr) => {
|
||||
fileLogger.error("SSH executeFile stream error:", streamErr);
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({ error: "Execution stream error" });
|
||||
sshConn.client.exec(executeCommand, (err, stream) => {
|
||||
if (err) {
|
||||
fileLogger.error("SSH executeFile error:", err);
|
||||
return res.status(500).json({ error: "Failed to execute file" });
|
||||
}
|
||||
|
||||
let output = "";
|
||||
let errorOutput = "";
|
||||
|
||||
stream.on("data", (data) => {
|
||||
output += data.toString();
|
||||
});
|
||||
|
||||
stream.stderr.on("data", (data) => {
|
||||
errorOutput += data.toString();
|
||||
});
|
||||
|
||||
stream.on("close", (code) => {
|
||||
const exitCodeMatch = output.match(/EXIT_CODE:(\d+)$/);
|
||||
const actualExitCode = exitCodeMatch
|
||||
? parseInt(exitCodeMatch[1])
|
||||
: code;
|
||||
const cleanOutput = output.replace(/EXIT_CODE:\d+$/, "").trim();
|
||||
|
||||
fileLogger.info("File execution completed", {
|
||||
operation: "execute_file",
|
||||
sessionId,
|
||||
filePath,
|
||||
exitCode: actualExitCode,
|
||||
outputLength: cleanOutput.length,
|
||||
errorLength: errorOutput.length,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
exitCode: actualExitCode,
|
||||
output: cleanOutput,
|
||||
error: errorOutput,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
});
|
||||
|
||||
stream.on("error", (streamErr) => {
|
||||
fileLogger.error("SSH executeFile stream error:", streamErr);
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({ error: "Execution stream error" });
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const PORT = 30004;
|
||||
app.listen(PORT, async () => {
|
||||
fileLogger.success("File Manager API server started", {
|
||||
operation: "server_start",
|
||||
port: PORT,
|
||||
const PORT = 30004;
|
||||
app.listen(PORT, async () => {
|
||||
try {
|
||||
await authManager.initialize();
|
||||
} catch (err) {
|
||||
fileLogger.error("Failed to initialize AuthManager", err, {
|
||||
operation: "auth_init_error",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize AuthManager for JWT verification
|
||||
try {
|
||||
await authManager.initialize();
|
||||
fileLogger.info("AuthManager initialized for file manager", {
|
||||
operation: "auth_init",
|
||||
});
|
||||
} catch (err) {
|
||||
fileLogger.error("Failed to initialize AuthManager", err, {
|
||||
operation: "auth_init_error",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -282,30 +282,30 @@ app.use(
|
||||
origin: (origin, callback) => {
|
||||
// Allow requests with no origin (like mobile apps or curl requests)
|
||||
if (!origin) return callback(null, true);
|
||||
|
||||
|
||||
// Allow localhost and 127.0.0.1 for development
|
||||
const allowedOrigins = [
|
||||
"http://localhost:5173",
|
||||
"http://localhost:3000",
|
||||
"http://localhost:3000",
|
||||
"http://127.0.0.1:5173",
|
||||
"http://127.0.0.1:3000"
|
||||
"http://127.0.0.1:3000",
|
||||
];
|
||||
|
||||
|
||||
// Allow any HTTPS origin (production deployments)
|
||||
if (origin.startsWith("https://")) {
|
||||
return callback(null, true);
|
||||
}
|
||||
|
||||
|
||||
// Allow any HTTP origin for self-hosted scenarios
|
||||
if (origin.startsWith("http://")) {
|
||||
return callback(null, true);
|
||||
}
|
||||
|
||||
|
||||
// Check against allowed development origins
|
||||
if (allowedOrigins.includes(origin)) {
|
||||
return callback(null, true);
|
||||
}
|
||||
|
||||
|
||||
// Reject other origins
|
||||
callback(new Error("Not allowed by CORS"));
|
||||
},
|
||||
@@ -327,7 +327,9 @@ app.use(authManager.createAuthMiddleware());
|
||||
|
||||
const hostStatuses: Map<number, StatusEntry> = new Map();
|
||||
|
||||
async function fetchAllHosts(userId: string): Promise<SSHHostWithCredentials[]> {
|
||||
async function fetchAllHosts(
|
||||
userId: string,
|
||||
): Promise<SSHHostWithCredentials[]> {
|
||||
try {
|
||||
const hosts = await SimpleDBOps.select(
|
||||
getDb().select().from(sshData).where(eq(sshData.userId, userId)),
|
||||
@@ -366,13 +368,16 @@ async function fetchHostById(
|
||||
statsLogger.debug("User data locked - cannot fetch host", {
|
||||
operation: "fetchHostById_data_locked",
|
||||
userId,
|
||||
hostId: id
|
||||
hostId: id,
|
||||
});
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const hosts = await SimpleDBOps.select(
|
||||
getDb().select().from(sshData).where(and(eq(sshData.id, id), eq(sshData.userId, userId))),
|
||||
getDb()
|
||||
.select()
|
||||
.from(sshData)
|
||||
.where(and(eq(sshData.id, id), eq(sshData.userId, userId))),
|
||||
"ssh_data",
|
||||
userId,
|
||||
);
|
||||
@@ -512,7 +517,14 @@ function buildSshConfig(host: SSHHostWithCredentials): ConnectConfig {
|
||||
"aes256-cbc",
|
||||
"3des-cbc",
|
||||
],
|
||||
hmac: ["hmac-sha2-256-etm@openssh.com", "hmac-sha2-512-etm@openssh.com", "hmac-sha2-256", "hmac-sha2-512", "hmac-sha1", "hmac-md5"],
|
||||
hmac: [
|
||||
"hmac-sha2-256-etm@openssh.com",
|
||||
"hmac-sha2-512-etm@openssh.com",
|
||||
"hmac-sha2-256",
|
||||
"hmac-sha2-512",
|
||||
"hmac-sha1",
|
||||
"hmac-md5",
|
||||
],
|
||||
compress: ["none", "zlib@openssh.com", "zlib"],
|
||||
},
|
||||
} as ConnectConfig;
|
||||
@@ -879,7 +891,7 @@ app.get("/status", async (req, res) => {
|
||||
if (!SimpleDBOps.isUserDataUnlocked(userId)) {
|
||||
return res.status(401).json({
|
||||
error: "Session expired - please log in again",
|
||||
code: "SESSION_EXPIRED"
|
||||
code: "SESSION_EXPIRED",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -901,7 +913,7 @@ app.get("/status/:id", validateHostId, async (req, res) => {
|
||||
if (!SimpleDBOps.isUserDataUnlocked(userId)) {
|
||||
return res.status(401).json({
|
||||
error: "Session expired - please log in again",
|
||||
code: "SESSION_EXPIRED"
|
||||
code: "SESSION_EXPIRED",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -933,7 +945,7 @@ app.post("/refresh", async (req, res) => {
|
||||
if (!SimpleDBOps.isUserDataUnlocked(userId)) {
|
||||
return res.status(401).json({
|
||||
error: "Session expired - please log in again",
|
||||
code: "SESSION_EXPIRED"
|
||||
code: "SESSION_EXPIRED",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -949,7 +961,7 @@ app.get("/metrics/:id", validateHostId, async (req, res) => {
|
||||
if (!SimpleDBOps.isUserDataUnlocked(userId)) {
|
||||
return res.status(401).json({
|
||||
error: "Session expired - please log in again",
|
||||
code: "SESSION_EXPIRED"
|
||||
code: "SESSION_EXPIRED",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -996,38 +1008,22 @@ app.get("/metrics/:id", validateHostId, async (req, res) => {
|
||||
});
|
||||
|
||||
process.on("SIGINT", () => {
|
||||
statsLogger.info("Received SIGINT, shutting down gracefully");
|
||||
connectionPool.destroy();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
process.on("SIGTERM", () => {
|
||||
statsLogger.info("Received SIGTERM, shutting down gracefully");
|
||||
connectionPool.destroy();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
const PORT = 30005;
|
||||
app.listen(PORT, async () => {
|
||||
statsLogger.success("Server Stats API server started", {
|
||||
operation: "server_start",
|
||||
port: PORT,
|
||||
});
|
||||
|
||||
// Initialize AuthManager for JWT verification
|
||||
try {
|
||||
await authManager.initialize();
|
||||
statsLogger.info("AuthManager initialized for metrics collection", {
|
||||
operation: "auth_init",
|
||||
});
|
||||
} catch (err) {
|
||||
statsLogger.error("Failed to initialize AuthManager", err, {
|
||||
operation: "auth_init_error",
|
||||
});
|
||||
}
|
||||
|
||||
// Skip initial poll - requires user authentication
|
||||
statsLogger.info("Server ready - status polling will begin with first authenticated request", {
|
||||
operation: "server_ready",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,16 +9,13 @@ import { SimpleDBOps } from "../utils/simple-db-ops.js";
|
||||
import { AuthManager } from "../utils/auth-manager.js";
|
||||
import { UserCrypto } from "../utils/user-crypto.js";
|
||||
|
||||
// Get auth instances
|
||||
const authManager = AuthManager.getInstance();
|
||||
const userCrypto = UserCrypto.getInstance();
|
||||
|
||||
// Track user connections for rate limiting
|
||||
const userConnections = new Map<string, Set<WebSocket>>();
|
||||
|
||||
const wss = new WebSocketServer({
|
||||
port: 30002,
|
||||
// WebSocket authentication during handshake
|
||||
verifyClient: async (info) => {
|
||||
try {
|
||||
const url = parseUrl(info.req.url!, true);
|
||||
@@ -28,7 +25,7 @@ const wss = new WebSocketServer({
|
||||
sshLogger.warn("WebSocket connection rejected: missing token", {
|
||||
operation: "websocket_auth_reject",
|
||||
reason: "missing_token",
|
||||
ip: info.req.socket.remoteAddress
|
||||
ip: info.req.socket.remoteAddress,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
@@ -39,23 +36,24 @@ const wss = new WebSocketServer({
|
||||
sshLogger.warn("WebSocket connection rejected: invalid token", {
|
||||
operation: "websocket_auth_reject",
|
||||
reason: "invalid_token",
|
||||
ip: info.req.socket.remoteAddress
|
||||
ip: info.req.socket.remoteAddress,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check for TOTP pending (should not allow terminal access during TOTP)
|
||||
if (payload.pendingTOTP) {
|
||||
sshLogger.warn("WebSocket connection rejected: TOTP verification pending", {
|
||||
operation: "websocket_auth_reject",
|
||||
reason: "totp_pending",
|
||||
userId: payload.userId,
|
||||
ip: info.req.socket.remoteAddress
|
||||
});
|
||||
sshLogger.warn(
|
||||
"WebSocket connection rejected: TOTP verification pending",
|
||||
{
|
||||
operation: "websocket_auth_reject",
|
||||
reason: "totp_pending",
|
||||
userId: payload.userId,
|
||||
ip: info.req.socket.remoteAddress,
|
||||
},
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check connection limits per user (max 3 concurrent connections)
|
||||
const existingConnections = userConnections.get(payload.userId);
|
||||
if (existingConnections && existingConnections.size >= 3) {
|
||||
sshLogger.warn("WebSocket connection rejected: too many connections", {
|
||||
@@ -63,39 +61,29 @@ const wss = new WebSocketServer({
|
||||
reason: "connection_limit",
|
||||
userId: payload.userId,
|
||||
currentConnections: existingConnections.size,
|
||||
ip: info.req.socket.remoteAddress
|
||||
ip: info.req.socket.remoteAddress,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
// Note: We don't need to attach user info to request anymore
|
||||
// Connection handler will re-verify JWT directly from URL
|
||||
|
||||
sshLogger.info("WebSocket connection authenticated", {
|
||||
operation: "websocket_auth_success",
|
||||
userId: payload.userId,
|
||||
ip: info.req.socket.remoteAddress
|
||||
});
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
sshLogger.error("WebSocket authentication error", error, {
|
||||
operation: "websocket_auth_error",
|
||||
ip: info.req.socket.remoteAddress
|
||||
ip: info.req.socket.remoteAddress,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
sshLogger.success("SSH Terminal WebSocket server started with authentication", {
|
||||
operation: "server_start",
|
||||
port: 30002,
|
||||
features: ["JWT_auth", "connection_limits", "data_access_control"]
|
||||
features: ["JWT_auth", "connection_limits", "data_access_control"],
|
||||
});
|
||||
|
||||
wss.on("connection", async (ws: WebSocket, req) => {
|
||||
// Linus principle: eliminate complexity - always parse JWT from URL directly
|
||||
let userId: string | undefined;
|
||||
let userPayload: any;
|
||||
|
||||
@@ -104,75 +92,76 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
const token = url.query.token as string;
|
||||
|
||||
if (!token) {
|
||||
sshLogger.warn("WebSocket connection rejected: missing token in connection", {
|
||||
operation: "websocket_connection_reject",
|
||||
reason: "missing_token",
|
||||
ip: req.socket.remoteAddress
|
||||
});
|
||||
sshLogger.warn(
|
||||
"WebSocket connection rejected: missing token in connection",
|
||||
{
|
||||
operation: "websocket_connection_reject",
|
||||
reason: "missing_token",
|
||||
ip: req.socket.remoteAddress,
|
||||
},
|
||||
);
|
||||
ws.close(1008, "Authentication required");
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = await authManager.verifyJWTToken(token);
|
||||
if (!payload) {
|
||||
sshLogger.warn("WebSocket connection rejected: invalid token in connection", {
|
||||
operation: "websocket_connection_reject",
|
||||
reason: "invalid_token",
|
||||
ip: req.socket.remoteAddress
|
||||
});
|
||||
sshLogger.warn(
|
||||
"WebSocket connection rejected: invalid token in connection",
|
||||
{
|
||||
operation: "websocket_connection_reject",
|
||||
reason: "invalid_token",
|
||||
ip: req.socket.remoteAddress,
|
||||
},
|
||||
);
|
||||
ws.close(1008, "Authentication required");
|
||||
return;
|
||||
}
|
||||
|
||||
userId = payload.userId;
|
||||
userPayload = payload;
|
||||
|
||||
} catch (error) {
|
||||
sshLogger.error("WebSocket JWT verification failed during connection", error, {
|
||||
operation: "websocket_connection_auth_error",
|
||||
ip: req.socket.remoteAddress
|
||||
});
|
||||
sshLogger.error(
|
||||
"WebSocket JWT verification failed during connection",
|
||||
error,
|
||||
{
|
||||
operation: "websocket_connection_auth_error",
|
||||
ip: req.socket.remoteAddress,
|
||||
},
|
||||
);
|
||||
ws.close(1008, "Authentication required");
|
||||
return;
|
||||
}
|
||||
|
||||
// Check data access permissions
|
||||
const dataKey = userCrypto.getUserDataKey(userId);
|
||||
if (!dataKey) {
|
||||
sshLogger.warn("WebSocket connection rejected: data locked", {
|
||||
operation: "websocket_data_locked",
|
||||
userId,
|
||||
ip: req.socket.remoteAddress
|
||||
ip: req.socket.remoteAddress,
|
||||
});
|
||||
ws.send(JSON.stringify({
|
||||
type: "error",
|
||||
message: "Data locked - re-authenticate with password",
|
||||
code: "DATA_LOCKED"
|
||||
}));
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
message: "Data locked - re-authenticate with password",
|
||||
code: "DATA_LOCKED",
|
||||
}),
|
||||
);
|
||||
ws.close(1008, "Data access required");
|
||||
return;
|
||||
}
|
||||
|
||||
// Track user connections for limits
|
||||
if (!userConnections.has(userId)) {
|
||||
userConnections.set(userId, new Set());
|
||||
}
|
||||
const userWs = userConnections.get(userId)!;
|
||||
userWs.add(ws);
|
||||
|
||||
sshLogger.info("WebSocket connection established", {
|
||||
operation: "websocket_connection_established",
|
||||
userId,
|
||||
userConnections: userWs.size,
|
||||
ip: req.socket.remoteAddress
|
||||
});
|
||||
|
||||
let sshConn: Client | null = null;
|
||||
let sshStream: ClientChannel | null = null;
|
||||
let pingInterval: NodeJS.Timeout | null = null;
|
||||
|
||||
ws.on("close", () => {
|
||||
// Clean up user connection tracking
|
||||
const userWs = userConnections.get(userId);
|
||||
if (userWs) {
|
||||
userWs.delete(ws);
|
||||
@@ -181,29 +170,24 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
}
|
||||
}
|
||||
|
||||
sshLogger.info("WebSocket connection closed", {
|
||||
operation: "websocket_connection_closed",
|
||||
userId,
|
||||
remainingConnections: userWs?.size || 0
|
||||
});
|
||||
|
||||
cleanupSSH();
|
||||
});
|
||||
|
||||
ws.on("message", (msg: RawData) => {
|
||||
// Verify user still has data access before processing any messages
|
||||
const currentDataKey = userCrypto.getUserDataKey(userId);
|
||||
if (!currentDataKey) {
|
||||
sshLogger.warn("WebSocket message rejected: data access expired", {
|
||||
operation: "websocket_message_rejected",
|
||||
userId,
|
||||
reason: "data_access_expired"
|
||||
reason: "data_access_expired",
|
||||
});
|
||||
ws.send(JSON.stringify({
|
||||
type: "error",
|
||||
message: "Data access expired - please re-authenticate",
|
||||
code: "DATA_EXPIRED"
|
||||
}));
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
message: "Data access expired - please re-authenticate",
|
||||
code: "DATA_EXPIRED",
|
||||
}),
|
||||
);
|
||||
ws.close(1008, "Data access expired");
|
||||
return;
|
||||
}
|
||||
@@ -225,7 +209,6 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
|
||||
switch (type) {
|
||||
case "connectToHost":
|
||||
// Ensure userId is attached to hostConfig for secure credential resolution
|
||||
if (data.hostConfig) {
|
||||
data.hostConfig.userId = userId;
|
||||
}
|
||||
@@ -390,7 +373,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
const credential = credentials[0];
|
||||
resolvedCredentials = {
|
||||
password: credential.password,
|
||||
key: credential.privateKey || credential.key, // prefer new privateKey field
|
||||
key: credential.privateKey || credential.key,
|
||||
keyPassword: credential.keyPassword,
|
||||
keyType: credential.keyType,
|
||||
authType: credential.authType,
|
||||
@@ -480,16 +463,12 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
|
||||
setupPingInterval();
|
||||
|
||||
// Change to initial path if specified
|
||||
if (initialPath && initialPath.trim() !== "") {
|
||||
// Send cd command to change directory
|
||||
const cdCommand = `cd "${initialPath.replace(/"/g, '\\"')}" && pwd\n`;
|
||||
stream.write(cdCommand);
|
||||
}
|
||||
|
||||
// Execute command if specified
|
||||
if (executeCommand && executeCommand.trim() !== "") {
|
||||
// Wait a moment for the cd command to complete, then execute the command
|
||||
setTimeout(() => {
|
||||
const command = `${executeCommand}\n`;
|
||||
stream.write(command);
|
||||
@@ -604,7 +583,14 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
"aes256-cbc",
|
||||
"3des-cbc",
|
||||
],
|
||||
hmac: ["hmac-sha2-256-etm@openssh.com", "hmac-sha2-512-etm@openssh.com", "hmac-sha2-256", "hmac-sha2-512", "hmac-sha1", "hmac-md5"],
|
||||
hmac: [
|
||||
"hmac-sha2-256-etm@openssh.com",
|
||||
"hmac-sha2-512-etm@openssh.com",
|
||||
"hmac-sha2-256",
|
||||
"hmac-sha2-512",
|
||||
"hmac-sha1",
|
||||
"hmac-md5",
|
||||
],
|
||||
compress: ["none", "zlib@openssh.com", "zlib"],
|
||||
},
|
||||
};
|
||||
|
||||
@@ -22,33 +22,27 @@ const app = express();
|
||||
app.use(
|
||||
cors({
|
||||
origin: (origin, callback) => {
|
||||
// Allow requests with no origin (like mobile apps or curl requests)
|
||||
if (!origin) return callback(null, true);
|
||||
|
||||
// Allow localhost and 127.0.0.1 for development
|
||||
|
||||
const allowedOrigins = [
|
||||
"http://localhost:5173",
|
||||
"http://localhost:3000",
|
||||
"http://localhost:3000",
|
||||
"http://127.0.0.1:5173",
|
||||
"http://127.0.0.1:3000"
|
||||
"http://127.0.0.1:3000",
|
||||
];
|
||||
|
||||
// Allow any HTTPS origin (production deployments)
|
||||
|
||||
if (origin.startsWith("https://")) {
|
||||
return callback(null, true);
|
||||
}
|
||||
|
||||
// Allow any HTTP origin for self-hosted scenarios
|
||||
|
||||
if (origin.startsWith("http://")) {
|
||||
return callback(null, true);
|
||||
}
|
||||
|
||||
// Check against allowed development origins
|
||||
|
||||
if (allowedOrigins.includes(origin)) {
|
||||
return callback(null, true);
|
||||
}
|
||||
|
||||
// Reject other origins
|
||||
|
||||
callback(new Error("Not allowed by CORS"));
|
||||
},
|
||||
credentials: true,
|
||||
@@ -158,18 +152,15 @@ function getTunnelMarker(tunnelName: string) {
|
||||
return `TUNNEL_MARKER_${tunnelName.replace(/[^a-zA-Z0-9]/g, "_")}`;
|
||||
}
|
||||
|
||||
function cleanupTunnelResources(tunnelName: string, forceCleanup = false): void {
|
||||
tunnelLogger.info(`Cleaning up resources for tunnel '${tunnelName}' (force=${forceCleanup})`);
|
||||
|
||||
// Prevent concurrent cleanup operations
|
||||
function cleanupTunnelResources(
|
||||
tunnelName: string,
|
||||
forceCleanup = false,
|
||||
): void {
|
||||
if (cleanupInProgress.has(tunnelName)) {
|
||||
tunnelLogger.info(`Cleanup already in progress for '${tunnelName}', skipping`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Protect connecting tunnels unless forced
|
||||
if (!forceCleanup && tunnelConnecting.has(tunnelName)) {
|
||||
tunnelLogger.info(`Tunnel '${tunnelName}' is connecting, skipping cleanup (use force=true to override)`);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -183,8 +174,6 @@ function cleanupTunnelResources(tunnelName: string, forceCleanup = false): void
|
||||
tunnelLogger.error(
|
||||
`Failed to kill remote tunnel for '${tunnelName}': ${err.message}`,
|
||||
);
|
||||
} else {
|
||||
tunnelLogger.info(`Successfully cleaned up remote tunnel processes for '${tunnelName}'`);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
@@ -210,7 +199,6 @@ function cleanupTunnelResources(tunnelName: string, forceCleanup = false): void
|
||||
try {
|
||||
const conn = activeTunnels.get(tunnelName);
|
||||
if (conn) {
|
||||
tunnelLogger.info(`Closing SSH2 connection for tunnel '${tunnelName}'`);
|
||||
conn.end();
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -220,7 +208,6 @@ function cleanupTunnelResources(tunnelName: string, forceCleanup = false): void
|
||||
);
|
||||
}
|
||||
activeTunnels.delete(tunnelName);
|
||||
tunnelLogger.info(`Removed tunnel '${tunnelName}' from activeTunnels`);
|
||||
}
|
||||
|
||||
if (tunnelVerifications.has(tunnelName)) {
|
||||
@@ -454,10 +441,8 @@ async function connectSSHTunnel(
|
||||
return;
|
||||
}
|
||||
|
||||
// Mark tunnel as connecting to protect from cleanup
|
||||
tunnelConnecting.add(tunnelName);
|
||||
|
||||
// Force cleanup any existing resources before new connection
|
||||
cleanupTunnelResources(tunnelName, true);
|
||||
|
||||
if (retryAttempt === 0) {
|
||||
@@ -519,7 +504,7 @@ async function connectSSHTunnel(
|
||||
const credential = credentials[0];
|
||||
resolvedSourceCredentials = {
|
||||
password: credential.password,
|
||||
sshKey: credential.privateKey || credential.key, // prefer new privateKey field
|
||||
sshKey: credential.privateKey || credential.key,
|
||||
keyPassword: credential.keyPassword,
|
||||
keyType: credential.keyType,
|
||||
authMethod: credential.authType,
|
||||
@@ -549,11 +534,10 @@ async function connectSSHTunnel(
|
||||
authMethod: tunnelConfig.endpointAuthMethod,
|
||||
};
|
||||
|
||||
tunnelLogger.info(`Source credentials for '${tunnelName}': authMethod=${resolvedSourceCredentials.authMethod}, hasPassword=${!!resolvedSourceCredentials.password}, hasSSHKey=${!!resolvedSourceCredentials.sshKey}`);
|
||||
tunnelLogger.info(`Final endpoint credentials for '${tunnelName}': authMethod=${resolvedEndpointCredentials.authMethod}, hasPassword=${!!resolvedEndpointCredentials.password}, hasSSHKey=${!!resolvedEndpointCredentials.sshKey}, credentialId=${tunnelConfig.endpointCredentialId}`);
|
||||
|
||||
// Validate that we have usable endpoint credentials
|
||||
if (resolvedEndpointCredentials.authMethod === "password" && !resolvedEndpointCredentials.password) {
|
||||
if (
|
||||
resolvedEndpointCredentials.authMethod === "password" &&
|
||||
!resolvedEndpointCredentials.password
|
||||
) {
|
||||
const errorMessage = `Cannot connect tunnel '${tunnelName}': endpoint host requires password authentication but no plaintext password available. Enable autostart for endpoint host or configure credentials in tunnel connection.`;
|
||||
tunnelLogger.error(errorMessage);
|
||||
broadcastTunnelStatus(tunnelName, {
|
||||
@@ -564,7 +548,10 @@ async function connectSSHTunnel(
|
||||
return;
|
||||
}
|
||||
|
||||
if (resolvedEndpointCredentials.authMethod === "key" && !resolvedEndpointCredentials.sshKey) {
|
||||
if (
|
||||
resolvedEndpointCredentials.authMethod === "key" &&
|
||||
!resolvedEndpointCredentials.sshKey
|
||||
) {
|
||||
const errorMessage = `Cannot connect tunnel '${tunnelName}': endpoint host requires key authentication but no plaintext key available. Enable autostart for endpoint host or configure credentials in tunnel connection.`;
|
||||
tunnelLogger.error(errorMessage);
|
||||
broadcastTunnelStatus(tunnelName, {
|
||||
@@ -591,12 +578,11 @@ async function connectSSHTunnel(
|
||||
const credential = credentials[0];
|
||||
resolvedEndpointCredentials = {
|
||||
password: credential.password,
|
||||
sshKey: credential.privateKey || credential.key, // prefer new privateKey field
|
||||
sshKey: credential.privateKey || credential.key,
|
||||
keyPassword: credential.keyPassword,
|
||||
keyType: credential.keyType,
|
||||
authMethod: credential.authType,
|
||||
};
|
||||
tunnelLogger.info(`Resolved endpoint credentials from DB for '${tunnelName}': authMethod=${resolvedEndpointCredentials.authMethod}, hasPassword=${!!resolvedEndpointCredentials.password}, hasSSHKey=${!!resolvedEndpointCredentials.sshKey}`);
|
||||
} else {
|
||||
tunnelLogger.warn("No endpoint credentials found in database", {
|
||||
operation: "tunnel_connect",
|
||||
@@ -646,7 +632,6 @@ async function connectSSHTunnel(
|
||||
clearTimeout(connectionTimeout);
|
||||
tunnelLogger.error(`SSH error for '${tunnelName}': ${err.message}`);
|
||||
|
||||
// Clear connecting state on error
|
||||
tunnelConnecting.delete(tunnelName);
|
||||
|
||||
if (activeRetryTimers.has(tunnelName)) {
|
||||
@@ -677,7 +662,6 @@ async function connectSSHTunnel(
|
||||
conn.on("close", () => {
|
||||
clearTimeout(connectionTimeout);
|
||||
|
||||
// Clear connecting state on close
|
||||
tunnelConnecting.delete(tunnelName);
|
||||
|
||||
if (activeRetryTimers.has(tunnelName)) {
|
||||
@@ -722,8 +706,6 @@ async function connectSSHTunnel(
|
||||
tunnelCmd = `exec -a "${tunnelMarker}" sshpass -p '${resolvedEndpointCredentials.password || ""}' ssh -v -N -o StrictHostKeyChecking=no -o ExitOnForwardFailure=yes -o ServerAliveInterval=30 -o ServerAliveCountMax=3 -o GatewayPorts=yes -R ${tunnelConfig.endpointPort}:localhost:${tunnelConfig.sourcePort} ${tunnelConfig.endpointUsername}@${tunnelConfig.endpointIP}`;
|
||||
}
|
||||
|
||||
tunnelLogger.info(`Executing tunnel command for '${tunnelName}': ${tunnelCmd.replace(/sshpass -p '[^']*'/g, 'sshpass -p [HIDDEN]').replace(/echo '[^']*'/g, 'echo [HIDDEN]')}`);
|
||||
|
||||
conn.exec(tunnelCmd, (err, stream) => {
|
||||
if (err) {
|
||||
tunnelLogger.error(
|
||||
@@ -750,7 +732,6 @@ async function connectSSHTunnel(
|
||||
!manualDisconnects.has(tunnelName) &&
|
||||
activeTunnels.has(tunnelName)
|
||||
) {
|
||||
// Clear connecting state on successful connection
|
||||
tunnelConnecting.delete(tunnelName);
|
||||
|
||||
broadcastTunnelStatus(tunnelName, {
|
||||
@@ -827,7 +808,6 @@ async function connectSSHTunnel(
|
||||
stream.stdout?.on("data", (data: Buffer) => {
|
||||
const output = data.toString().trim();
|
||||
if (output) {
|
||||
tunnelLogger.info(`SSH stdout for '${tunnelName}': ${output}`);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -836,25 +816,42 @@ async function connectSSHTunnel(
|
||||
stream.stderr.on("data", (data) => {
|
||||
const errorMsg = data.toString().trim();
|
||||
if (errorMsg) {
|
||||
tunnelLogger.error(`SSH stderr for '${tunnelName}': ${errorMsg}`);
|
||||
const isDebugMessage =
|
||||
errorMsg.startsWith("debug1:") ||
|
||||
errorMsg.startsWith("debug2:") ||
|
||||
errorMsg.startsWith("debug3:") ||
|
||||
errorMsg.includes("Reading configuration data") ||
|
||||
errorMsg.includes("include /etc/ssh/ssh_config.d") ||
|
||||
errorMsg.includes("matched no files") ||
|
||||
errorMsg.includes("Applying options for");
|
||||
|
||||
// Check for specific SSH errors
|
||||
if (errorMsg.includes("sshpass: command not found") || errorMsg.includes("sshpass not found")) {
|
||||
if (!isDebugMessage) {
|
||||
tunnelLogger.error(`SSH stderr for '${tunnelName}': ${errorMsg}`);
|
||||
}
|
||||
|
||||
if (
|
||||
errorMsg.includes("sshpass: command not found") ||
|
||||
errorMsg.includes("sshpass not found")
|
||||
) {
|
||||
broadcastTunnelStatus(tunnelName, {
|
||||
connected: false,
|
||||
status: CONNECTION_STATES.FAILED,
|
||||
reason: "sshpass tool not found on source host. Please install sshpass or use SSH key authentication.",
|
||||
reason:
|
||||
"sshpass tool not found on source host. Please install sshpass or use SSH key authentication.",
|
||||
});
|
||||
}
|
||||
|
||||
// Check for port forwarding errors
|
||||
if (errorMsg.includes("remote port forwarding failed") || errorMsg.includes("Error: remote port forwarding failed")) {
|
||||
if (
|
||||
errorMsg.includes("remote port forwarding failed") ||
|
||||
errorMsg.includes("Error: remote port forwarding failed")
|
||||
) {
|
||||
const portMatch = errorMsg.match(/listen port (\d+)/);
|
||||
const port = portMatch ? portMatch[1] : tunnelConfig.endpointPort;
|
||||
|
||||
tunnelLogger.error(`Port forwarding failed for tunnel '${tunnelName}' on port ${port}. This prevents tunnel establishment.`);
|
||||
tunnelLogger.error(
|
||||
`Port forwarding failed for tunnel '${tunnelName}' on port ${port}. This prevents tunnel establishment.`,
|
||||
);
|
||||
|
||||
// Close the connection immediately to prevent retries
|
||||
if (activeTunnels.has(tunnelName)) {
|
||||
const conn = activeTunnels.get(tunnelName);
|
||||
if (conn) {
|
||||
@@ -905,7 +902,14 @@ async function connectSSHTunnel(
|
||||
"aes256-cbc",
|
||||
"3des-cbc",
|
||||
],
|
||||
hmac: ["hmac-sha2-256-etm@openssh.com", "hmac-sha2-512-etm@openssh.com", "hmac-sha2-256", "hmac-sha2-512", "hmac-sha1", "hmac-md5"],
|
||||
hmac: [
|
||||
"hmac-sha2-256-etm@openssh.com",
|
||||
"hmac-sha2-512-etm@openssh.com",
|
||||
"hmac-sha2-256",
|
||||
"hmac-sha2-512",
|
||||
"hmac-sha1",
|
||||
"hmac-md5",
|
||||
],
|
||||
compress: ["none", "zlib@openssh.com", "zlib"],
|
||||
},
|
||||
};
|
||||
@@ -975,9 +979,7 @@ async function killRemoteTunnelByMarker(
|
||||
callback: (err?: Error) => void,
|
||||
) {
|
||||
const tunnelMarker = getTunnelMarker(tunnelName);
|
||||
tunnelLogger.info(`Attempting to kill remote tunnel processes with marker '${tunnelMarker}' on source host ${tunnelConfig.sourceIP}`);
|
||||
|
||||
// Resolve source credentials using same logic as main tunnel connection
|
||||
let resolvedSourceCredentials = {
|
||||
password: tunnelConfig.sourcePassword,
|
||||
sshKey: tunnelConfig.sourceSSHKey,
|
||||
@@ -1049,7 +1051,14 @@ async function killRemoteTunnelByMarker(
|
||||
"aes256-cbc",
|
||||
"3des-cbc",
|
||||
],
|
||||
hmac: ["hmac-sha2-256-etm@openssh.com", "hmac-sha2-512-etm@openssh.com", "hmac-sha2-256", "hmac-sha2-512", "hmac-sha1", "hmac-md5"],
|
||||
hmac: [
|
||||
"hmac-sha2-256-etm@openssh.com",
|
||||
"hmac-sha2-512-etm@openssh.com",
|
||||
"hmac-sha2-256",
|
||||
"hmac-sha2-512",
|
||||
"hmac-sha1",
|
||||
"hmac-md5",
|
||||
],
|
||||
compress: ["none", "zlib@openssh.com", "zlib"],
|
||||
},
|
||||
};
|
||||
@@ -1085,7 +1094,6 @@ async function killRemoteTunnelByMarker(
|
||||
}
|
||||
|
||||
conn.on("ready", () => {
|
||||
// First, check for existing processes and get their PIDs
|
||||
const checkCmd = `ps aux | grep -E '(${tunnelMarker}|ssh.*-R.*${tunnelConfig.endpointPort}:localhost:${tunnelConfig.sourcePort}.*${tunnelConfig.endpointUsername}@${tunnelConfig.endpointIP}|sshpass.*ssh.*-R.*${tunnelConfig.endpointPort})' | grep -v grep`;
|
||||
|
||||
conn.exec(checkCmd, (err, stream) => {
|
||||
@@ -1095,31 +1103,27 @@ async function killRemoteTunnelByMarker(
|
||||
const output = data.toString().trim();
|
||||
if (output) {
|
||||
foundProcesses = true;
|
||||
tunnelLogger.info(`Found running tunnel processes for '${tunnelName}': ${output}`);
|
||||
}
|
||||
});
|
||||
|
||||
stream.on("close", () => {
|
||||
if (!foundProcesses) {
|
||||
tunnelLogger.info(`No running tunnel processes found for '${tunnelName}', cleanup not needed`);
|
||||
conn.end();
|
||||
callback();
|
||||
return;
|
||||
}
|
||||
|
||||
// Execute kill commands sequentially for better control
|
||||
const killCmds = [
|
||||
`pkill -TERM -f '${tunnelMarker}'`,
|
||||
`sleep 1 && pkill -f 'ssh.*-R.*${tunnelConfig.endpointPort}:localhost:${tunnelConfig.sourcePort}.*${tunnelConfig.endpointUsername}@${tunnelConfig.endpointIP}'`,
|
||||
`sleep 1 && pkill -f 'sshpass.*ssh.*-R.*${tunnelConfig.endpointPort}'`,
|
||||
`sleep 2 && pkill -9 -f '${tunnelMarker}'`, // Force kill after delay
|
||||
`sleep 2 && pkill -9 -f '${tunnelMarker}'`,
|
||||
];
|
||||
|
||||
let commandIndex = 0;
|
||||
|
||||
function executeNextKillCommand() {
|
||||
if (commandIndex >= killCmds.length) {
|
||||
// Final verification
|
||||
conn.exec(checkCmd, (err, verifyStream) => {
|
||||
let stillRunning = false;
|
||||
|
||||
@@ -1127,15 +1131,17 @@ async function killRemoteTunnelByMarker(
|
||||
const output = data.toString().trim();
|
||||
if (output) {
|
||||
stillRunning = true;
|
||||
tunnelLogger.warn(`Processes still running after cleanup for '${tunnelName}': ${output}`);
|
||||
tunnelLogger.warn(
|
||||
`Processes still running after cleanup for '${tunnelName}': ${output}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
verifyStream.on("close", () => {
|
||||
if (!stillRunning) {
|
||||
tunnelLogger.info(`All tunnel processes successfully terminated for '${tunnelName}'`);
|
||||
} else {
|
||||
tunnelLogger.warn(`Some tunnel processes may still be running for '${tunnelName}'`);
|
||||
if (stillRunning) {
|
||||
tunnelLogger.warn(
|
||||
`Some tunnel processes may still be running for '${tunnelName}'`,
|
||||
);
|
||||
}
|
||||
conn.end();
|
||||
callback();
|
||||
@@ -1148,13 +1154,13 @@ async function killRemoteTunnelByMarker(
|
||||
|
||||
conn.exec(killCmd, (err, stream) => {
|
||||
if (err) {
|
||||
tunnelLogger.warn(`Kill command ${commandIndex + 1} failed for '${tunnelName}': ${err.message}`);
|
||||
tunnelLogger.warn(
|
||||
`Kill command ${commandIndex + 1} failed for '${tunnelName}': ${err.message}`,
|
||||
);
|
||||
} else {
|
||||
tunnelLogger.info(`Executed kill command ${commandIndex + 1} for '${tunnelName}': ${killCmd.replace(/sleep \d+ && /, '')}`);
|
||||
}
|
||||
|
||||
stream.on("close", (code) => {
|
||||
tunnelLogger.info(`Kill command ${commandIndex + 1} completed with code ${code} for '${tunnelName}'`);
|
||||
commandIndex++;
|
||||
executeNextKillCommand();
|
||||
});
|
||||
@@ -1162,14 +1168,15 @@ async function killRemoteTunnelByMarker(
|
||||
stream.on("data", (data) => {
|
||||
const output = data.toString().trim();
|
||||
if (output) {
|
||||
tunnelLogger.info(`Kill command ${commandIndex + 1} output for '${tunnelName}': ${output}`);
|
||||
}
|
||||
});
|
||||
|
||||
stream.stderr.on("data", (data) => {
|
||||
const output = data.toString().trim();
|
||||
if (output && !output.includes("debug1")) {
|
||||
tunnelLogger.warn(`Kill command ${commandIndex + 1} stderr for '${tunnelName}': ${output}`);
|
||||
tunnelLogger.warn(
|
||||
`Kill command ${commandIndex + 1} stderr for '${tunnelName}': ${output}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1181,7 +1188,9 @@ async function killRemoteTunnelByMarker(
|
||||
});
|
||||
|
||||
conn.on("error", (err) => {
|
||||
tunnelLogger.error(`Failed to connect to source host for killing tunnel '${tunnelName}': ${err.message}`);
|
||||
tunnelLogger.error(
|
||||
`Failed to connect to source host for killing tunnel '${tunnelName}': ${err.message}`,
|
||||
);
|
||||
callback(err);
|
||||
});
|
||||
|
||||
@@ -1212,8 +1221,6 @@ app.post("/ssh/tunnel/connect", (req, res) => {
|
||||
|
||||
const tunnelName = tunnelConfig.name;
|
||||
|
||||
// Clean up any existing resources before starting new connection
|
||||
tunnelLogger.info(`Starting new connection for '${tunnelName}', cleaning up any existing resources`);
|
||||
cleanupTunnelResources(tunnelName);
|
||||
|
||||
manualDisconnects.delete(tunnelName);
|
||||
@@ -1247,8 +1254,6 @@ app.post("/ssh/tunnel/disconnect", (req, res) => {
|
||||
activeRetryTimers.delete(tunnelName);
|
||||
}
|
||||
|
||||
// Immediately clean up active connections (force cleanup)
|
||||
tunnelLogger.info(`Manual disconnect requested for '${tunnelName}', cleaning up resources`);
|
||||
cleanupTunnelResources(tunnelName, true);
|
||||
|
||||
broadcastTunnelStatus(tunnelName, {
|
||||
@@ -1287,8 +1292,6 @@ app.post("/ssh/tunnel/cancel", (req, res) => {
|
||||
countdownIntervals.delete(tunnelName);
|
||||
}
|
||||
|
||||
// Immediately clean up active connections for cancel operation too (force cleanup)
|
||||
tunnelLogger.info(`Cancel requested for '${tunnelName}', cleaning up resources`);
|
||||
cleanupTunnelResources(tunnelName, true);
|
||||
|
||||
broadcastTunnelStatus(tunnelName, {
|
||||
@@ -1309,11 +1312,9 @@ app.post("/ssh/tunnel/cancel", (req, res) => {
|
||||
|
||||
async function initializeAutoStartTunnels(): Promise<void> {
|
||||
try {
|
||||
// Get internal auth token from SystemCrypto
|
||||
const systemCrypto = SystemCrypto.getInstance();
|
||||
const internalAuthToken = await systemCrypto.getInternalAuthToken();
|
||||
|
||||
// Get autostart hosts for tunnel configs
|
||||
const autostartResponse = await axios.get(
|
||||
"http://localhost:30001/ssh/db/host/internal",
|
||||
{
|
||||
@@ -1324,7 +1325,6 @@ async function initializeAutoStartTunnels(): Promise<void> {
|
||||
},
|
||||
);
|
||||
|
||||
// Get all hosts for endpointHost resolution
|
||||
const allHostsResponse = await axios.get(
|
||||
"http://localhost:30001/ssh/db/host/internal/all",
|
||||
{
|
||||
@@ -1339,7 +1339,9 @@ async function initializeAutoStartTunnels(): Promise<void> {
|
||||
const allHosts: SSHHost[] = allHostsResponse.data || [];
|
||||
const autoStartTunnels: TunnelConfig[] = [];
|
||||
|
||||
tunnelLogger.info(`Found ${autostartHosts.length} autostart hosts and ${allHosts.length} total hosts for endpointHost resolution`);
|
||||
tunnelLogger.info(
|
||||
`Found ${autostartHosts.length} autostart hosts and ${allHosts.length} total hosts for endpointHost resolution`,
|
||||
);
|
||||
|
||||
for (const host of autostartHosts) {
|
||||
if (host.enableTunnel && host.tunnelConnections) {
|
||||
@@ -1352,50 +1354,39 @@ async function initializeAutoStartTunnels(): Promise<void> {
|
||||
);
|
||||
|
||||
if (endpointHost) {
|
||||
tunnelLogger.info(`Setting up tunnel credentials for '${host.name || `${host.username}@${host.ip}`}' -> '${endpointHost.name || `${endpointHost.username}@${endpointHost.ip}`}': sourceAutostart=${!!host.autostartPassword}, endpointAutostart=${!!endpointHost.autostartPassword}, endpointEncrypted=${!!endpointHost.password}`);
|
||||
|
||||
// Debug: Log actual credential availability
|
||||
tunnelLogger.info(`Source host credentials debug:`, {
|
||||
hostId: host.id,
|
||||
hasAutostartPassword: !!host.autostartPassword,
|
||||
hasAutostartKey: !!host.autostartKey,
|
||||
hasEncryptedPassword: !!host.password,
|
||||
hasEncryptedKey: !!host.key,
|
||||
authType: host.authType
|
||||
});
|
||||
|
||||
tunnelLogger.info(`Endpoint host credentials debug:`, {
|
||||
hostId: endpointHost.id,
|
||||
hasAutostartPassword: !!endpointHost.autostartPassword,
|
||||
hasAutostartKey: !!endpointHost.autostartKey,
|
||||
hasEncryptedPassword: !!endpointHost.password,
|
||||
hasEncryptedKey: !!endpointHost.key,
|
||||
authType: endpointHost.authType
|
||||
});
|
||||
|
||||
const tunnelConfig: TunnelConfig = {
|
||||
name: `${host.name || `${host.username}@${host.ip}`}_${tunnelConnection.sourcePort}_${tunnelConnection.endpointPort}`,
|
||||
hostName: host.name || `${host.username}@${host.ip}`,
|
||||
sourceIP: host.ip,
|
||||
sourceSSHPort: host.port,
|
||||
sourceUsername: host.username,
|
||||
// Prefer autostart credentials for source host, fallback to encrypted credentials
|
||||
sourcePassword: host.autostartPassword || host.password,
|
||||
sourceAuthMethod: host.authType,
|
||||
sourceSSHKey: host.autostartKey || host.key,
|
||||
sourceKeyPassword: host.autostartKeyPassword || host.keyPassword,
|
||||
sourceKeyPassword:
|
||||
host.autostartKeyPassword || host.keyPassword,
|
||||
sourceKeyType: host.keyType,
|
||||
sourceCredentialId: host.credentialId,
|
||||
sourceUserId: host.userId,
|
||||
endpointIP: endpointHost.ip,
|
||||
endpointSSHPort: endpointHost.port,
|
||||
endpointUsername: endpointHost.username,
|
||||
// Prefer TunnelConnection credentials, then autostart credentials, fallback to encrypted credentials
|
||||
endpointPassword: tunnelConnection.endpointPassword || endpointHost.autostartPassword || endpointHost.password,
|
||||
endpointAuthMethod: tunnelConnection.endpointAuthType || endpointHost.authType,
|
||||
endpointSSHKey: tunnelConnection.endpointKey || endpointHost.autostartKey || endpointHost.key,
|
||||
endpointKeyPassword: tunnelConnection.endpointKeyPassword || endpointHost.autostartKeyPassword || endpointHost.keyPassword,
|
||||
endpointKeyType: tunnelConnection.endpointKeyType || endpointHost.keyType,
|
||||
endpointPassword:
|
||||
tunnelConnection.endpointPassword ||
|
||||
endpointHost.autostartPassword ||
|
||||
endpointHost.password,
|
||||
endpointAuthMethod:
|
||||
tunnelConnection.endpointAuthType || endpointHost.authType,
|
||||
endpointSSHKey:
|
||||
tunnelConnection.endpointKey ||
|
||||
endpointHost.autostartKey ||
|
||||
endpointHost.key,
|
||||
endpointKeyPassword:
|
||||
tunnelConnection.endpointKeyPassword ||
|
||||
endpointHost.autostartKeyPassword ||
|
||||
endpointHost.keyPassword,
|
||||
endpointKeyType:
|
||||
tunnelConnection.endpointKeyType || endpointHost.keyType,
|
||||
endpointCredentialId: endpointHost.credentialId,
|
||||
endpointUserId: endpointHost.userId,
|
||||
sourcePort: tunnelConnection.sourcePort,
|
||||
@@ -1406,24 +1397,30 @@ async function initializeAutoStartTunnels(): Promise<void> {
|
||||
isPinned: host.pin,
|
||||
};
|
||||
|
||||
// Validate source and endpoint credentials availability
|
||||
const hasSourcePassword = host.autostartPassword;
|
||||
const hasSourceKey = host.autostartKey;
|
||||
const hasEndpointPassword = tunnelConnection.endpointPassword || endpointHost.autostartPassword;
|
||||
const hasEndpointKey = tunnelConnection.endpointKey || endpointHost.autostartKey;
|
||||
const hasEndpointPassword =
|
||||
tunnelConnection.endpointPassword ||
|
||||
endpointHost.autostartPassword;
|
||||
const hasEndpointKey =
|
||||
tunnelConnection.endpointKey || endpointHost.autostartKey;
|
||||
|
||||
if (!hasSourcePassword && !hasSourceKey) {
|
||||
tunnelLogger.warn(`Tunnel '${tunnelConfig.name}' may fail: source host '${host.name || `${host.username}@${host.ip}`}' has no plaintext credentials. Enable autostart for this host to use unattended tunneling.`);
|
||||
tunnelLogger.warn(
|
||||
`Tunnel '${tunnelConfig.name}' may fail: source host '${host.name || `${host.username}@${host.ip}`}' has no plaintext credentials. Enable autostart for this host to use unattended tunneling.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!hasEndpointPassword && !hasEndpointKey) {
|
||||
tunnelLogger.warn(`Tunnel '${tunnelConfig.name}' may fail: endpoint host '${endpointHost.name || `${endpointHost.username}@${endpointHost.ip}`}' has no plaintext credentials. Consider enabling autostart for this host or configuring credentials in tunnel connection.`);
|
||||
tunnelLogger.warn(
|
||||
`Tunnel '${tunnelConfig.name}' may fail: endpoint host '${endpointHost.name || `${endpointHost.username}@${endpointHost.ip}`}' has no plaintext credentials. Consider enabling autostart for this host or configuring credentials in tunnel connection.`,
|
||||
);
|
||||
}
|
||||
|
||||
autoStartTunnels.push(tunnelConfig);
|
||||
} else {
|
||||
tunnelLogger.error(
|
||||
`Failed to find endpointHost '${tunnelConnection.endpointHost}' for tunnel from ${host.name || `${host.username}@${host.ip}`}. Available hosts: ${allHosts.map(h => h.name || `${h.username}@${h.ip}`).join(', ')}`,
|
||||
`Failed to find endpointHost '${tunnelConnection.endpointHost}' for tunnel from ${host.name || `${host.username}@${host.ip}`}. Available hosts: ${allHosts.map((h) => h.name || `${h.username}@${h.ip}`).join(", ")}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1431,8 +1428,6 @@ async function initializeAutoStartTunnels(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
tunnelLogger.info(`Found ${autoStartTunnels.length} auto-start tunnels`);
|
||||
|
||||
for (const tunnelConfig of autoStartTunnels) {
|
||||
tunnelConfigs.set(tunnelConfig.name, tunnelConfig);
|
||||
|
||||
@@ -1454,10 +1449,6 @@ async function initializeAutoStartTunnels(): Promise<void> {
|
||||
|
||||
const PORT = 30003;
|
||||
app.listen(PORT, () => {
|
||||
tunnelLogger.success("SSH Tunnel API server started", {
|
||||
operation: "server_start",
|
||||
port: PORT,
|
||||
});
|
||||
setTimeout(() => {
|
||||
initializeAutoStartTunnels();
|
||||
}, 2000);
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
// npx tsc -p tsconfig.node.json
|
||||
// node ./dist/backend/starter.js
|
||||
|
||||
import "dotenv/config";
|
||||
import dotenv from "dotenv";
|
||||
import { promises as fs } from "fs";
|
||||
@@ -13,33 +10,15 @@ import { systemLogger, versionLogger } from "./utils/logger.js";
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
// Load persistent .env file from data directory (where database is stored)
|
||||
// Always try to load from data directory, regardless of NODE_ENV
|
||||
const dataDir = process.env.DATA_DIR || "./db/data";
|
||||
const envPath = path.join(dataDir, ".env");
|
||||
try {
|
||||
await fs.access(envPath);
|
||||
// Load the persistent .env file and override process.env
|
||||
const persistentConfig = dotenv.config({ path: envPath });
|
||||
if (persistentConfig.parsed) {
|
||||
// Override process.env with values from persistent config
|
||||
Object.assign(process.env, persistentConfig.parsed);
|
||||
}
|
||||
systemLogger.info(`Loaded persistent configuration from ${envPath}`, {
|
||||
operation: "config_load",
|
||||
hasDatabaseKey: !!persistentConfig.parsed?.DATABASE_KEY,
|
||||
databaseKeyLength: persistentConfig.parsed?.DATABASE_KEY?.length || 0,
|
||||
hasJwtSecret: !!persistentConfig.parsed?.JWT_SECRET,
|
||||
jwtSecretLength: persistentConfig.parsed?.JWT_SECRET?.length || 0,
|
||||
hasInternalAuthToken: !!persistentConfig.parsed?.INTERNAL_AUTH_TOKEN,
|
||||
internalAuthTokenLength: persistentConfig.parsed?.INTERNAL_AUTH_TOKEN?.length || 0
|
||||
});
|
||||
} catch {
|
||||
// Config file doesn't exist yet, will be created on first run
|
||||
systemLogger.info("No persistent config found, will create on first run", {
|
||||
operation: "config_init"
|
||||
});
|
||||
}
|
||||
} catch {}
|
||||
|
||||
const version = process.env.VERSION || "unknown";
|
||||
versionLogger.info(`Termix Backend starting - Version: ${version}`, {
|
||||
@@ -47,72 +26,73 @@ import { systemLogger, versionLogger } from "./utils/logger.js";
|
||||
version: version,
|
||||
});
|
||||
|
||||
// Initialize system crypto keys FIRST (after .env is loaded)
|
||||
const systemCrypto = SystemCrypto.getInstance();
|
||||
await systemCrypto.initializeJWTSecret();
|
||||
await systemCrypto.initializeDatabaseKey();
|
||||
await systemCrypto.initializeInternalAuthToken();
|
||||
|
||||
// Auto-initialize SSL/TLS configuration
|
||||
await AutoSSLSetup.initialize();
|
||||
|
||||
// Initialize database first - required before other services
|
||||
systemLogger.info("Initializing database...", {
|
||||
operation: "database_init"
|
||||
});
|
||||
const dbModule = await import("./database/db/index.js");
|
||||
await dbModule.initializeDatabase();
|
||||
systemLogger.success("Database initialized successfully", {
|
||||
operation: "database_init_complete"
|
||||
});
|
||||
|
||||
// Production environment security checks
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
systemLogger.info("Running production environment security checks...", {
|
||||
operation: "security_checks",
|
||||
});
|
||||
|
||||
if (process.env.NODE_ENV === "production") {
|
||||
const securityIssues: string[] = [];
|
||||
|
||||
// Check JWT and database keys (auto-generated if missing - warnings only)
|
||||
if (!process.env.JWT_SECRET) {
|
||||
systemLogger.warn("JWT_SECRET not set - using auto-generated keys (consider setting for production)", {
|
||||
operation: "security_warning",
|
||||
note: "Auto-generated keys are secure but not persistent across deployments"
|
||||
});
|
||||
systemLogger.warn(
|
||||
"JWT_SECRET not set - using auto-generated keys (consider setting for production)",
|
||||
{
|
||||
operation: "security_warning",
|
||||
note: "Auto-generated keys are secure but not persistent across deployments",
|
||||
},
|
||||
);
|
||||
} else if (process.env.JWT_SECRET.length < 64) {
|
||||
securityIssues.push("JWT_SECRET should be at least 64 characters in production");
|
||||
securityIssues.push(
|
||||
"JWT_SECRET should be at least 64 characters in production",
|
||||
);
|
||||
}
|
||||
|
||||
if (!process.env.DATABASE_KEY) {
|
||||
systemLogger.warn("DATABASE_KEY not set - using auto-generated keys (consider setting for production)", {
|
||||
operation: "security_warning",
|
||||
note: "Auto-generated keys are secure but not persistent across deployments"
|
||||
});
|
||||
systemLogger.warn(
|
||||
"DATABASE_KEY not set - using auto-generated keys (consider setting for production)",
|
||||
{
|
||||
operation: "security_warning",
|
||||
note: "Auto-generated keys are secure but not persistent across deployments",
|
||||
},
|
||||
);
|
||||
} else if (process.env.DATABASE_KEY.length < 64) {
|
||||
securityIssues.push("DATABASE_KEY should be at least 64 characters in production");
|
||||
securityIssues.push(
|
||||
"DATABASE_KEY should be at least 64 characters in production",
|
||||
);
|
||||
}
|
||||
|
||||
if (!process.env.INTERNAL_AUTH_TOKEN) {
|
||||
systemLogger.warn("INTERNAL_AUTH_TOKEN not set - using auto-generated token (consider setting for production)", {
|
||||
operation: "security_warning",
|
||||
note: "Auto-generated tokens are secure but not persistent across deployments"
|
||||
});
|
||||
systemLogger.warn(
|
||||
"INTERNAL_AUTH_TOKEN not set - using auto-generated token (consider setting for production)",
|
||||
{
|
||||
operation: "security_warning",
|
||||
note: "Auto-generated tokens are secure but not persistent across deployments",
|
||||
},
|
||||
);
|
||||
} else if (process.env.INTERNAL_AUTH_TOKEN.length < 32) {
|
||||
securityIssues.push("INTERNAL_AUTH_TOKEN should be at least 32 characters in production");
|
||||
securityIssues.push(
|
||||
"INTERNAL_AUTH_TOKEN should be at least 32 characters in production",
|
||||
);
|
||||
}
|
||||
|
||||
// Check database file encryption
|
||||
if (process.env.DB_FILE_ENCRYPTION === 'false') {
|
||||
securityIssues.push("Database file encryption should be enabled in production");
|
||||
if (process.env.DB_FILE_ENCRYPTION === "false") {
|
||||
securityIssues.push(
|
||||
"Database file encryption should be enabled in production",
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// Check CORS configuration warning
|
||||
systemLogger.warn("Production deployment detected - ensure CORS is properly configured", {
|
||||
operation: "security_checks",
|
||||
warning: "Verify frontend domain whitelist"
|
||||
});
|
||||
systemLogger.warn(
|
||||
"Production deployment detected - ensure CORS is properly configured",
|
||||
{
|
||||
operation: "security_checks",
|
||||
warning: "Verify frontend domain whitelist",
|
||||
},
|
||||
);
|
||||
|
||||
if (securityIssues.length > 0) {
|
||||
systemLogger.error("SECURITY ISSUES DETECTED IN PRODUCTION:", {
|
||||
@@ -127,59 +107,19 @@ import { systemLogger, versionLogger } from "./utils/logger.js";
|
||||
});
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
systemLogger.success("Production security checks passed", {
|
||||
operation: "security_checks_complete",
|
||||
});
|
||||
}
|
||||
|
||||
systemLogger.info("Initializing backend services...", {
|
||||
operation: "startup",
|
||||
environment: process.env.NODE_ENV || "development",
|
||||
});
|
||||
|
||||
// Initialize simplified authentication system
|
||||
const authManager = AuthManager.getInstance();
|
||||
await authManager.initialize();
|
||||
DataCrypto.initialize();
|
||||
|
||||
// System crypto keys already initialized above
|
||||
|
||||
systemLogger.info("Security system initialized (KEK-DEK architecture + SystemCrypto)", {
|
||||
operation: "security_init",
|
||||
});
|
||||
|
||||
// Load database-dependent modules after database initialization
|
||||
systemLogger.info("Starting database API server...", {
|
||||
operation: "api_server_init"
|
||||
});
|
||||
await import("./database/database.js");
|
||||
|
||||
// Load modules that depend on database and encryption
|
||||
systemLogger.info("Starting SSH services...", {
|
||||
operation: "ssh_services_init"
|
||||
});
|
||||
await import("./ssh/terminal.js");
|
||||
await import("./ssh/tunnel.js");
|
||||
await import("./ssh/file-manager.js");
|
||||
await import("./ssh/server-stats.js");
|
||||
|
||||
systemLogger.success("All backend services initialized successfully", {
|
||||
operation: "startup_complete",
|
||||
services: [
|
||||
"database",
|
||||
"encryption",
|
||||
"terminal",
|
||||
"tunnel",
|
||||
"file_manager",
|
||||
"stats",
|
||||
],
|
||||
version: version,
|
||||
});
|
||||
|
||||
// Display SSL configuration info
|
||||
AutoSSLSetup.logSSLInfo();
|
||||
|
||||
process.on("SIGINT", () => {
|
||||
systemLogger.info(
|
||||
"Received SIGINT signal, initiating graceful shutdown...",
|
||||
|
||||
@@ -23,27 +23,16 @@ interface JWTPayload {
|
||||
exp?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* AuthManager - Simplified authentication manager
|
||||
*
|
||||
* Responsibilities:
|
||||
* - JWT generation and validation
|
||||
* - Authentication middleware
|
||||
* - User login/logout
|
||||
*
|
||||
* No more two-layer sessions - use UserKeyManager directly
|
||||
*/
|
||||
class AuthManager {
|
||||
private static instance: AuthManager;
|
||||
private systemCrypto: SystemCrypto;
|
||||
private userCrypto: UserCrypto;
|
||||
private invalidatedTokens: Set<string> = new Set(); // Track invalidated JWT tokens
|
||||
private invalidatedTokens: Set<string> = new Set();
|
||||
|
||||
private constructor() {
|
||||
this.systemCrypto = SystemCrypto.getInstance();
|
||||
this.userCrypto = UserCrypto.getInstance();
|
||||
|
||||
// Set up callback to invalidate JWT tokens when data sessions expire
|
||||
|
||||
this.userCrypto.setSessionExpiredCallback((userId: string) => {
|
||||
this.invalidateUserTokens(userId);
|
||||
});
|
||||
@@ -56,80 +45,58 @@ class AuthManager {
|
||||
return this.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize authentication system
|
||||
*/
|
||||
async initialize(): Promise<void> {
|
||||
await this.systemCrypto.initializeJWTSecret();
|
||||
databaseLogger.info("AuthManager initialized", {
|
||||
operation: "auth_init"
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* User registration
|
||||
*/
|
||||
async registerUser(userId: string, password: string): Promise<void> {
|
||||
await this.userCrypto.setupUserEncryption(userId, password);
|
||||
}
|
||||
|
||||
/**
|
||||
* User login with lazy encryption migration
|
||||
*/
|
||||
async authenticateUser(userId: string, password: string): Promise<boolean> {
|
||||
const authenticated = await this.userCrypto.authenticateUser(userId, password);
|
||||
const authenticated = await this.userCrypto.authenticateUser(
|
||||
userId,
|
||||
password,
|
||||
);
|
||||
|
||||
if (authenticated) {
|
||||
// Trigger lazy encryption migration for user's sensitive fields
|
||||
await this.performLazyEncryptionMigration(userId);
|
||||
}
|
||||
|
||||
return authenticated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform lazy encryption migration for user's sensitive data
|
||||
* This runs asynchronously after successful login
|
||||
*/
|
||||
private async performLazyEncryptionMigration(userId: string): Promise<void> {
|
||||
try {
|
||||
const userDataKey = this.getUserDataKey(userId);
|
||||
if (!userDataKey) {
|
||||
databaseLogger.warn("Cannot perform lazy encryption migration - user data key not available", {
|
||||
operation: "lazy_encryption_migration_no_key",
|
||||
userId,
|
||||
});
|
||||
databaseLogger.warn(
|
||||
"Cannot perform lazy encryption migration - user data key not available",
|
||||
{
|
||||
operation: "lazy_encryption_migration_no_key",
|
||||
userId,
|
||||
},
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Import database connection - need to access raw SQLite for migration
|
||||
const { getSqlite, saveMemoryDatabaseToFile } = await import("../database/db/index.js");
|
||||
const { getSqlite, saveMemoryDatabaseToFile } = await import(
|
||||
"../database/db/index.js"
|
||||
);
|
||||
|
||||
// Database should already be initialized by starter.ts, but ensure we can access it
|
||||
const sqlite = getSqlite();
|
||||
|
||||
// Perform the migration
|
||||
const migrationResult = await DataCrypto.migrateUserSensitiveFields(
|
||||
userId,
|
||||
userDataKey,
|
||||
sqlite
|
||||
sqlite,
|
||||
);
|
||||
|
||||
if (migrationResult.migrated) {
|
||||
// Save the in-memory database to disk to persist the migration
|
||||
await saveMemoryDatabaseToFile();
|
||||
|
||||
databaseLogger.success("Lazy encryption migration completed for user", {
|
||||
operation: "lazy_encryption_migration_success",
|
||||
userId,
|
||||
migratedTables: migrationResult.migratedTables,
|
||||
migratedFieldsCount: migrationResult.migratedFieldsCount,
|
||||
});
|
||||
} else {
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
// Log error but don't fail the login process
|
||||
databaseLogger.error("Lazy encryption migration failed", error, {
|
||||
operation: "lazy_encryption_migration_error",
|
||||
userId,
|
||||
@@ -138,12 +105,9 @@ class AuthManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate JWT Token
|
||||
*/
|
||||
async generateJWTToken(
|
||||
userId: string,
|
||||
options: { expiresIn?: string; pendingTOTP?: boolean } = {}
|
||||
options: { expiresIn?: string; pendingTOTP?: boolean } = {},
|
||||
): Promise<string> {
|
||||
const jwtSecret = await this.systemCrypto.getJWTSecret();
|
||||
|
||||
@@ -153,21 +117,13 @@ class AuthManager {
|
||||
}
|
||||
|
||||
return jwt.sign(payload, jwtSecret, {
|
||||
expiresIn: options.expiresIn || "24h"
|
||||
expiresIn: options.expiresIn || "24h",
|
||||
} as jwt.SignOptions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify JWT Token
|
||||
*/
|
||||
async verifyJWTToken(token: string): Promise<JWTPayload | null> {
|
||||
try {
|
||||
// Check if token is in invalidated list
|
||||
if (this.invalidatedTokens.has(token)) {
|
||||
databaseLogger.debug("JWT token is invalidated", {
|
||||
operation: "jwt_verify_invalidated",
|
||||
tokenPrefix: token.substring(0, 20) + "..."
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -177,58 +133,37 @@ class AuthManager {
|
||||
} catch (error) {
|
||||
databaseLogger.warn("JWT verification failed", {
|
||||
operation: "jwt_verify_failed",
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate JWT token (add to blacklist)
|
||||
*/
|
||||
invalidateJWTToken(token: string): void {
|
||||
this.invalidatedTokens.add(token);
|
||||
databaseLogger.info("JWT token invalidated", {
|
||||
operation: "jwt_invalidate",
|
||||
tokenPrefix: token.substring(0, 20) + "..."
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate all JWT tokens for a user (when data locks)
|
||||
*/
|
||||
invalidateUserTokens(userId: string): void {
|
||||
// Note: This is a simplified approach. In a production system, you might want
|
||||
// to track tokens by userId and invalidate them more precisely.
|
||||
// For now, we'll rely on the data lock mechanism to handle this.
|
||||
databaseLogger.info("User tokens invalidated due to data lock", {
|
||||
operation: "user_tokens_invalidate",
|
||||
userId
|
||||
userId,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to get secure cookie options based on request
|
||||
*/
|
||||
getSecureCookieOptions(req: any, maxAge: number = 24 * 60 * 60 * 1000) {
|
||||
return {
|
||||
httpOnly: true, // Prevent XSS attacks
|
||||
secure: req.secure || req.headers['x-forwarded-proto'] === 'https', // Detect HTTPS properly
|
||||
sameSite: "strict" as const, // Prevent CSRF attacks
|
||||
maxAge: maxAge, // Session duration in milliseconds
|
||||
path: "/", // Available site-wide
|
||||
httpOnly: true,
|
||||
secure: req.secure || req.headers["x-forwarded-proto"] === "https",
|
||||
sameSite: "strict" as const,
|
||||
maxAge: maxAge,
|
||||
path: "/",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Authentication middleware
|
||||
*/
|
||||
createAuthMiddleware() {
|
||||
return async (req: Request, res: Response, next: NextFunction) => {
|
||||
// Try to get JWT from secure HttpOnly cookie first
|
||||
let token = req.cookies?.jwt;
|
||||
|
||||
// Fallback to Authorization header for backward compatibility
|
||||
|
||||
if (!token) {
|
||||
const authHeader = req.headers["authorization"];
|
||||
if (authHeader?.startsWith("Bearer ")) {
|
||||
@@ -252,9 +187,6 @@ class AuthManager {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Data access middleware - requires user to have unlocked data
|
||||
*/
|
||||
createDataAccessMiddleware() {
|
||||
return async (req: Request, res: Response, next: NextFunction) => {
|
||||
const userId = (req as any).userId;
|
||||
@@ -266,7 +198,7 @@ class AuthManager {
|
||||
if (!dataKey) {
|
||||
return res.status(401).json({
|
||||
error: "Session expired - please log in again",
|
||||
code: "SESSION_EXPIRED"
|
||||
code: "SESSION_EXPIRED",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -275,9 +207,6 @@ class AuthManager {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin middleware - requires user to be authenticated and have admin privileges
|
||||
*/
|
||||
createAdminMiddleware() {
|
||||
return async (req: Request, res: Response, next: NextFunction) => {
|
||||
const authHeader = req.headers["authorization"];
|
||||
@@ -292,20 +221,25 @@ class AuthManager {
|
||||
return res.status(401).json({ error: "Invalid token" });
|
||||
}
|
||||
|
||||
// Check if user is admin
|
||||
try {
|
||||
const { db } = await import("../database/db/index.js");
|
||||
const { users } = await import("../database/db/schema.js");
|
||||
const { eq } = await import("drizzle-orm");
|
||||
|
||||
const user = await db.select().from(users).where(eq(users.id, payload.userId));
|
||||
const user = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.id, payload.userId));
|
||||
|
||||
if (!user || user.length === 0 || !user[0].is_admin) {
|
||||
databaseLogger.warn("Non-admin user attempted to access admin endpoint", {
|
||||
operation: "admin_access_denied",
|
||||
userId: payload.userId,
|
||||
endpoint: req.path,
|
||||
});
|
||||
databaseLogger.warn(
|
||||
"Non-admin user attempted to access admin endpoint",
|
||||
{
|
||||
operation: "admin_access_denied",
|
||||
userId: payload.userId,
|
||||
endpoint: req.path,
|
||||
},
|
||||
);
|
||||
return res.status(403).json({ error: "Admin access required" });
|
||||
}
|
||||
|
||||
@@ -317,38 +251,36 @@ class AuthManager {
|
||||
operation: "admin_check_failed",
|
||||
userId: payload.userId,
|
||||
});
|
||||
return res.status(500).json({ error: "Failed to verify admin privileges" });
|
||||
return res
|
||||
.status(500)
|
||||
.json({ error: "Failed to verify admin privileges" });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* User logout
|
||||
*/
|
||||
logoutUser(userId: string): void {
|
||||
this.userCrypto.logoutUser(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user data key
|
||||
*/
|
||||
getUserDataKey(userId: string): Buffer | null {
|
||||
return this.userCrypto.getUserDataKey(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user is unlocked
|
||||
*/
|
||||
isUserUnlocked(userId: string): boolean {
|
||||
return this.userCrypto.isUserUnlocked(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Change user password
|
||||
*/
|
||||
async changeUserPassword(userId: string, oldPassword: string, newPassword: string): Promise<boolean> {
|
||||
return await this.userCrypto.changeUserPassword(userId, oldPassword, newPassword);
|
||||
async changeUserPassword(
|
||||
userId: string,
|
||||
oldPassword: string,
|
||||
newPassword: string,
|
||||
): Promise<boolean> {
|
||||
return await this.userCrypto.changeUserPassword(
|
||||
userId,
|
||||
oldPassword,
|
||||
newPassword,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export { AuthManager, type AuthenticationResult, type JWTPayload };
|
||||
export { AuthManager, type AuthenticationResult, type JWTPayload };
|
||||
|
||||
@@ -4,123 +4,92 @@ import path from "path";
|
||||
import crypto from "crypto";
|
||||
import { systemLogger } from "./logger.js";
|
||||
|
||||
/**
|
||||
* Auto SSL Setup - Optional SSL certificate generation for Termix
|
||||
*
|
||||
* Linus principle: Simple defaults, optional security features
|
||||
* - SSL disabled by default to avoid setup complexity
|
||||
* - Auto-generates SSL certificates when enabled
|
||||
* - Uses container-appropriate paths
|
||||
* - Users can enable SSL by setting ENABLE_SSL=true
|
||||
*/
|
||||
export class AutoSSLSetup {
|
||||
private static readonly DATA_DIR = process.env.DATA_DIR || "./db/data";
|
||||
private static readonly SSL_DIR = path.join(AutoSSLSetup.DATA_DIR, "ssl");
|
||||
private static readonly CERT_FILE = path.join(AutoSSLSetup.SSL_DIR, "termix.crt");
|
||||
private static readonly KEY_FILE = path.join(AutoSSLSetup.SSL_DIR, "termix.key");
|
||||
private static readonly CERT_FILE = path.join(
|
||||
AutoSSLSetup.SSL_DIR,
|
||||
"termix.crt",
|
||||
);
|
||||
private static readonly KEY_FILE = path.join(
|
||||
AutoSSLSetup.SSL_DIR,
|
||||
"termix.key",
|
||||
);
|
||||
private static readonly ENV_FILE = path.join(AutoSSLSetup.DATA_DIR, ".env");
|
||||
|
||||
/**
|
||||
* Initialize SSL setup automatically during system startup
|
||||
*/
|
||||
static async initialize(): Promise<void> {
|
||||
try {
|
||||
systemLogger.info("Initializing SSL/TLS configuration...", {
|
||||
operation: "ssl_auto_init"
|
||||
});
|
||||
|
||||
// Check if SSL is already properly configured
|
||||
if (await this.isSSLConfigured()) {
|
||||
systemLogger.info("SSL configuration already exists and is valid", {
|
||||
operation: "ssl_already_configured"
|
||||
});
|
||||
|
||||
// Log certificate information for existing certificates
|
||||
await this.logCertificateInfo();
|
||||
return;
|
||||
}
|
||||
|
||||
// Auto-generate SSL certificates
|
||||
await this.generateSSLCertificates();
|
||||
|
||||
// Setup environment variables for SSL
|
||||
await this.setupEnvironmentVariables();
|
||||
|
||||
systemLogger.success("SSL/TLS configuration completed successfully", {
|
||||
operation: "ssl_auto_init_complete",
|
||||
https_port: process.env.SSL_PORT || "8443",
|
||||
note: "HTTPS/WSS is now enabled by default"
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
systemLogger.error("Failed to initialize SSL configuration", error, {
|
||||
operation: "ssl_auto_init_failed"
|
||||
operation: "ssl_auto_init_failed",
|
||||
});
|
||||
|
||||
// Don't crash the application - fallback to HTTP
|
||||
systemLogger.warn("Falling back to HTTP-only mode", {
|
||||
operation: "ssl_fallback_http"
|
||||
operation: "ssl_fallback_http",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if SSL is already properly configured
|
||||
*/
|
||||
private static async isSSLConfigured(): Promise<boolean> {
|
||||
try {
|
||||
// Check if certificate files exist
|
||||
await fs.access(this.CERT_FILE);
|
||||
await fs.access(this.KEY_FILE);
|
||||
|
||||
// Check if certificate is still valid (at least 30 days)
|
||||
execSync(`openssl x509 -in "${this.CERT_FILE}" -checkend 2592000 -noout`, {
|
||||
stdio: 'pipe'
|
||||
});
|
||||
|
||||
systemLogger.info("SSL certificate is valid and will expire in more than 30 days", {
|
||||
operation: "ssl_cert_check",
|
||||
cert_path: this.CERT_FILE
|
||||
});
|
||||
execSync(
|
||||
`openssl x509 -in "${this.CERT_FILE}" -checkend 2592000 -noout`,
|
||||
{
|
||||
stdio: "pipe",
|
||||
},
|
||||
);
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes('checkend')) {
|
||||
systemLogger.warn("SSL certificate is expired or expiring soon, will regenerate", {
|
||||
operation: "ssl_cert_expired",
|
||||
cert_path: this.CERT_FILE,
|
||||
error: error.message
|
||||
});
|
||||
if (error instanceof Error && error.message.includes("checkend")) {
|
||||
systemLogger.warn(
|
||||
"SSL certificate is expired or expiring soon, will regenerate",
|
||||
{
|
||||
operation: "ssl_cert_expired",
|
||||
cert_path: this.CERT_FILE,
|
||||
error: error.message,
|
||||
},
|
||||
);
|
||||
} else {
|
||||
systemLogger.info("SSL certificate not found or invalid, will generate new one", {
|
||||
operation: "ssl_cert_missing",
|
||||
cert_path: this.CERT_FILE
|
||||
});
|
||||
systemLogger.info(
|
||||
"SSL certificate not found or invalid, will generate new one",
|
||||
{
|
||||
operation: "ssl_cert_missing",
|
||||
cert_path: this.CERT_FILE,
|
||||
},
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate SSL certificates automatically
|
||||
*/
|
||||
private static async generateSSLCertificates(): Promise<void> {
|
||||
systemLogger.info("Generating SSL certificates for local development...", {
|
||||
operation: "ssl_cert_generation"
|
||||
operation: "ssl_cert_generation",
|
||||
});
|
||||
|
||||
try {
|
||||
// Check if OpenSSL is available
|
||||
try {
|
||||
execSync('openssl version', { stdio: 'pipe' });
|
||||
execSync("openssl version", { stdio: "pipe" });
|
||||
} catch (error) {
|
||||
throw new Error('OpenSSL is not installed or not available in PATH. Please install OpenSSL to enable SSL certificate generation.');
|
||||
throw new Error(
|
||||
"OpenSSL is not installed or not available in PATH. Please install OpenSSL to enable SSL certificate generation.",
|
||||
);
|
||||
}
|
||||
|
||||
// Create SSL directory
|
||||
await fs.mkdir(this.SSL_DIR, { recursive: true });
|
||||
|
||||
// Create OpenSSL config for comprehensive certificate
|
||||
const configFile = path.join(this.SSL_DIR, "openssl.conf");
|
||||
const opensslConfig = `
|
||||
[req]
|
||||
@@ -155,102 +124,106 @@ IP.2 = ::1
|
||||
|
||||
await fs.writeFile(configFile, opensslConfig);
|
||||
|
||||
// Generate private key
|
||||
execSync(`openssl genrsa -out "${this.KEY_FILE}" 2048`, { stdio: 'pipe' });
|
||||
|
||||
// Generate certificate
|
||||
execSync(`openssl req -new -x509 -key "${this.KEY_FILE}" -out "${this.CERT_FILE}" -days 365 -config "${configFile}" -extensions v3_req`, {
|
||||
stdio: 'pipe'
|
||||
execSync(`openssl genrsa -out "${this.KEY_FILE}" 2048`, {
|
||||
stdio: "pipe",
|
||||
});
|
||||
|
||||
// Set proper permissions
|
||||
execSync(
|
||||
`openssl req -new -x509 -key "${this.KEY_FILE}" -out "${this.CERT_FILE}" -days 365 -config "${configFile}" -extensions v3_req`,
|
||||
{
|
||||
stdio: "pipe",
|
||||
},
|
||||
);
|
||||
|
||||
await fs.chmod(this.KEY_FILE, 0o600);
|
||||
await fs.chmod(this.CERT_FILE, 0o644);
|
||||
|
||||
// Clean up temp config
|
||||
await fs.unlink(configFile);
|
||||
|
||||
systemLogger.success("SSL certificates generated successfully", {
|
||||
operation: "ssl_cert_generated",
|
||||
cert_path: this.CERT_FILE,
|
||||
key_path: this.KEY_FILE,
|
||||
valid_days: 365
|
||||
valid_days: 365,
|
||||
});
|
||||
|
||||
// Log certificate information
|
||||
await this.logCertificateInfo();
|
||||
|
||||
} catch (error) {
|
||||
throw new Error(`SSL certificate generation failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
throw new Error(
|
||||
`SSL certificate generation failed: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log certificate information including expiration date
|
||||
*/
|
||||
private static async logCertificateInfo(): Promise<void> {
|
||||
try {
|
||||
const subject = execSync(`openssl x509 -in "${this.CERT_FILE}" -noout -subject`, { stdio: 'pipe' }).toString().trim();
|
||||
const issuer = execSync(`openssl x509 -in "${this.CERT_FILE}" -noout -issuer`, { stdio: 'pipe' }).toString().trim();
|
||||
const notAfter = execSync(`openssl x509 -in "${this.CERT_FILE}" -noout -enddate`, { stdio: 'pipe' }).toString().trim();
|
||||
const notBefore = execSync(`openssl x509 -in "${this.CERT_FILE}" -noout -startdate`, { stdio: 'pipe' }).toString().trim();
|
||||
const subject = execSync(
|
||||
`openssl x509 -in "${this.CERT_FILE}" -noout -subject`,
|
||||
{ stdio: "pipe" },
|
||||
)
|
||||
.toString()
|
||||
.trim();
|
||||
const issuer = execSync(
|
||||
`openssl x509 -in "${this.CERT_FILE}" -noout -issuer`,
|
||||
{ stdio: "pipe" },
|
||||
)
|
||||
.toString()
|
||||
.trim();
|
||||
const notAfter = execSync(
|
||||
`openssl x509 -in "${this.CERT_FILE}" -noout -enddate`,
|
||||
{ stdio: "pipe" },
|
||||
)
|
||||
.toString()
|
||||
.trim();
|
||||
const notBefore = execSync(
|
||||
`openssl x509 -in "${this.CERT_FILE}" -noout -startdate`,
|
||||
{ stdio: "pipe" },
|
||||
)
|
||||
.toString()
|
||||
.trim();
|
||||
|
||||
systemLogger.info("SSL Certificate Information:", {
|
||||
operation: "ssl_cert_info",
|
||||
subject: subject.replace('subject=', ''),
|
||||
issuer: issuer.replace('issuer=', ''),
|
||||
valid_from: notBefore.replace('notBefore=', ''),
|
||||
valid_until: notAfter.replace('notAfter=', ''),
|
||||
note: "Certificate will auto-renew 30 days before expiration"
|
||||
subject: subject.replace("subject=", ""),
|
||||
issuer: issuer.replace("issuer=", ""),
|
||||
valid_from: notBefore.replace("notBefore=", ""),
|
||||
valid_until: notAfter.replace("notAfter=", ""),
|
||||
note: "Certificate will auto-renew 30 days before expiration",
|
||||
});
|
||||
} catch (error) {
|
||||
systemLogger.warn("Could not retrieve certificate information", {
|
||||
operation: "ssl_cert_info_error",
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup environment variables for SSL configuration
|
||||
*/
|
||||
private static async setupEnvironmentVariables(): Promise<void> {
|
||||
systemLogger.info("Configuring SSL environment variables...", {
|
||||
operation: "ssl_env_setup"
|
||||
});
|
||||
|
||||
// Use data directory paths for both production and development
|
||||
const certPath = this.CERT_FILE;
|
||||
const keyPath = this.KEY_FILE;
|
||||
|
||||
const sslEnvVars = {
|
||||
ENABLE_SSL: "false", // Disable SSL by default to avoid setup issues
|
||||
ENABLE_SSL: "false",
|
||||
SSL_PORT: process.env.SSL_PORT || "8443",
|
||||
SSL_CERT_PATH: certPath,
|
||||
SSL_KEY_PATH: keyPath,
|
||||
SSL_DOMAIN: "localhost"
|
||||
SSL_DOMAIN: "localhost",
|
||||
};
|
||||
|
||||
// Check if .env file exists
|
||||
let envContent = "";
|
||||
try {
|
||||
envContent = await fs.readFile(this.ENV_FILE, 'utf8');
|
||||
} catch {
|
||||
// .env doesn't exist, will create new one
|
||||
}
|
||||
envContent = await fs.readFile(this.ENV_FILE, "utf8");
|
||||
} catch {}
|
||||
|
||||
// Update or add SSL variables
|
||||
let updatedContent = envContent;
|
||||
let hasChanges = false;
|
||||
|
||||
for (const [key, value] of Object.entries(sslEnvVars)) {
|
||||
const regex = new RegExp(`^${key}=.*$`, 'm');
|
||||
const regex = new RegExp(`^${key}=.*$`, "m");
|
||||
|
||||
if (regex.test(updatedContent)) {
|
||||
// Update existing variable
|
||||
updatedContent = updatedContent.replace(regex, `${key}=${value}`);
|
||||
} else {
|
||||
// Add new variable
|
||||
if (!updatedContent.includes(`# SSL Configuration`)) {
|
||||
updatedContent += `\n# SSL Configuration (Auto-generated)\n`;
|
||||
}
|
||||
@@ -259,59 +232,28 @@ IP.2 = ::1
|
||||
}
|
||||
}
|
||||
|
||||
// Write updated .env file if there are changes
|
||||
if (hasChanges || !envContent) {
|
||||
await fs.writeFile(this.ENV_FILE, updatedContent.trim() + '\n');
|
||||
await fs.writeFile(this.ENV_FILE, updatedContent.trim() + "\n");
|
||||
|
||||
systemLogger.info("SSL environment variables configured", {
|
||||
operation: "ssl_env_configured",
|
||||
file: this.ENV_FILE,
|
||||
variables: Object.keys(sslEnvVars)
|
||||
variables: Object.keys(sslEnvVars),
|
||||
});
|
||||
}
|
||||
|
||||
// Update process.env for current session
|
||||
for (const [key, value] of Object.entries(sslEnvVars)) {
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get SSL configuration for nginx/server
|
||||
*/
|
||||
static getSSLConfig() {
|
||||
return {
|
||||
enabled: process.env.ENABLE_SSL === "true",
|
||||
port: parseInt(process.env.SSL_PORT || "8443"),
|
||||
certPath: process.env.SSL_CERT_PATH || this.CERT_FILE,
|
||||
keyPath: process.env.SSL_KEY_PATH || this.KEY_FILE,
|
||||
domain: process.env.SSL_DOMAIN || "localhost"
|
||||
domain: process.env.SSL_DOMAIN || "localhost",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Display SSL setup information
|
||||
*/
|
||||
static logSSLInfo(): void {
|
||||
const config = this.getSSLConfig();
|
||||
|
||||
if (config.enabled) {
|
||||
console.log(`
|
||||
╔══════════════════════════════════════════════════════════════╗
|
||||
║ 🔒 Termix SSL/TLS Enabled ║
|
||||
╠══════════════════════════════════════════════════════════════╣
|
||||
║ HTTPS Port: ${config.port.toString().padEnd(47)} ║
|
||||
║ HTTP Port: ${(process.env.PORT || "8080").padEnd(47)} ║
|
||||
║ Domain: ${config.domain.padEnd(47)} ║
|
||||
║ ║
|
||||
║ Access URLs: ║
|
||||
║ • HTTPS: https://localhost:${config.port.toString().padEnd(31)} ║
|
||||
║ • HTTP: http://localhost:${(process.env.PORT || "8080").padEnd(32)} ║
|
||||
║ ║
|
||||
║ WebSocket connections automatically use WSS over HTTPS ║
|
||||
║ Self-signed certificate will show browser warnings ║
|
||||
╚══════════════════════════════════════════════════════════════╝
|
||||
`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,31 +3,21 @@ import { LazyFieldEncryption } from "./lazy-field-encryption.js";
|
||||
import { UserCrypto } from "./user-crypto.js";
|
||||
import { databaseLogger } from "./logger.js";
|
||||
|
||||
/**
|
||||
* DataCrypto - Simplified database encryption
|
||||
*
|
||||
* Linus principles:
|
||||
* - Remove all "backward compatibility" garbage
|
||||
* - Remove all special case handling
|
||||
* - Data is either properly encrypted or operation fails
|
||||
* - No legacy data concept
|
||||
*/
|
||||
class DataCrypto {
|
||||
private static userCrypto: UserCrypto;
|
||||
|
||||
static initialize() {
|
||||
this.userCrypto = UserCrypto.getInstance();
|
||||
databaseLogger.info("DataCrypto initialized - no legacy compatibility", {
|
||||
operation: "data_crypto_init",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt record - simple and direct
|
||||
*/
|
||||
static encryptRecord(tableName: string, record: any, userId: string, userDataKey: Buffer): any {
|
||||
static encryptRecord(
|
||||
tableName: string,
|
||||
record: any,
|
||||
userId: string,
|
||||
userDataKey: Buffer,
|
||||
): any {
|
||||
const encryptedRecord = { ...record };
|
||||
const recordId = record.id || 'temp-' + Date.now();
|
||||
const recordId = record.id || "temp-" + Date.now();
|
||||
|
||||
for (const [fieldName, value] of Object.entries(record)) {
|
||||
if (FieldCrypto.shouldEncryptField(tableName, fieldName) && value) {
|
||||
@@ -35,7 +25,7 @@ class DataCrypto {
|
||||
value as string,
|
||||
userDataKey,
|
||||
recordId,
|
||||
fieldName
|
||||
fieldName,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -43,11 +33,12 @@ class DataCrypto {
|
||||
return encryptedRecord;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt record with lazy encryption support
|
||||
* Handles both encrypted and plaintext fields (from migration)
|
||||
*/
|
||||
static decryptRecord(tableName: string, record: any, userId: string, userDataKey: Buffer): any {
|
||||
static decryptRecord(
|
||||
tableName: string,
|
||||
record: any,
|
||||
userId: string,
|
||||
userDataKey: Buffer,
|
||||
): any {
|
||||
if (!record) return record;
|
||||
|
||||
const decryptedRecord = { ...record };
|
||||
@@ -55,12 +46,11 @@ class DataCrypto {
|
||||
|
||||
for (const [fieldName, value] of Object.entries(record)) {
|
||||
if (FieldCrypto.shouldEncryptField(tableName, fieldName) && value) {
|
||||
// Use lazy encryption to handle both plaintext and encrypted data
|
||||
decryptedRecord[fieldName] = LazyFieldEncryption.safeGetFieldValue(
|
||||
value as string,
|
||||
userDataKey,
|
||||
recordId,
|
||||
fieldName
|
||||
fieldName,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -68,22 +58,22 @@ class DataCrypto {
|
||||
return decryptedRecord;
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch decrypt
|
||||
*/
|
||||
static decryptRecords(tableName: string, records: any[], userId: string, userDataKey: Buffer): any[] {
|
||||
static decryptRecords(
|
||||
tableName: string,
|
||||
records: any[],
|
||||
userId: string,
|
||||
userDataKey: Buffer,
|
||||
): any[] {
|
||||
if (!Array.isArray(records)) return records;
|
||||
return records.map((record) => this.decryptRecord(tableName, record, userId, userDataKey));
|
||||
return records.map((record) =>
|
||||
this.decryptRecord(tableName, record, userId, userDataKey),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrate user's plaintext sensitive fields to encrypted format
|
||||
* Called during user login to gradually encrypt legacy data
|
||||
*/
|
||||
static async migrateUserSensitiveFields(
|
||||
userId: string,
|
||||
userDataKey: Buffer,
|
||||
db: any
|
||||
db: any,
|
||||
): Promise<{
|
||||
migrated: boolean;
|
||||
migratedTables: string[];
|
||||
@@ -94,45 +84,32 @@ class DataCrypto {
|
||||
let migratedFieldsCount = 0;
|
||||
|
||||
try {
|
||||
databaseLogger.info("Starting user sensitive fields migration", {
|
||||
operation: "user_sensitive_migration_start",
|
||||
userId,
|
||||
});
|
||||
|
||||
// Check if migration is needed
|
||||
const { needsMigration, plaintextFields } = await LazyFieldEncryption.checkUserNeedsMigration(
|
||||
userId,
|
||||
userDataKey,
|
||||
db
|
||||
);
|
||||
const { needsMigration, plaintextFields } =
|
||||
await LazyFieldEncryption.checkUserNeedsMigration(
|
||||
userId,
|
||||
userDataKey,
|
||||
db,
|
||||
);
|
||||
|
||||
if (!needsMigration) {
|
||||
databaseLogger.info("No migration needed for user", {
|
||||
operation: "user_sensitive_migration_not_needed",
|
||||
userId,
|
||||
});
|
||||
return { migrated: false, migratedTables: [], migratedFieldsCount: 0 };
|
||||
}
|
||||
|
||||
databaseLogger.info("User requires sensitive field migration", {
|
||||
operation: "user_sensitive_migration_required",
|
||||
userId,
|
||||
plaintextFieldsCount: plaintextFields.length,
|
||||
});
|
||||
|
||||
// Process ssh_data table
|
||||
const sshDataRecords = db.prepare("SELECT * FROM ssh_data WHERE user_id = ?").all(userId);
|
||||
const sshDataRecords = db
|
||||
.prepare("SELECT * FROM ssh_data WHERE user_id = ?")
|
||||
.all(userId);
|
||||
for (const record of sshDataRecords) {
|
||||
const sensitiveFields = LazyFieldEncryption.getSensitiveFieldsForTable('ssh_data');
|
||||
const { updatedRecord, migratedFields, needsUpdate } = LazyFieldEncryption.migrateRecordSensitiveFields(
|
||||
record,
|
||||
sensitiveFields,
|
||||
userDataKey,
|
||||
record.id.toString()
|
||||
);
|
||||
const sensitiveFields =
|
||||
LazyFieldEncryption.getSensitiveFieldsForTable("ssh_data");
|
||||
const { updatedRecord, migratedFields, needsUpdate } =
|
||||
LazyFieldEncryption.migrateRecordSensitiveFields(
|
||||
record,
|
||||
sensitiveFields,
|
||||
userDataKey,
|
||||
record.id.toString(),
|
||||
);
|
||||
|
||||
if (needsUpdate) {
|
||||
// Update the record in database
|
||||
const updateQuery = `
|
||||
UPDATE ssh_data
|
||||
SET password = ?, key = ?, key_password = ?, updated_at = CURRENT_TIMESTAMP
|
||||
@@ -142,30 +119,32 @@ class DataCrypto {
|
||||
updatedRecord.password || null,
|
||||
updatedRecord.key || null,
|
||||
updatedRecord.key_password || null,
|
||||
record.id
|
||||
record.id,
|
||||
);
|
||||
|
||||
migratedFieldsCount += migratedFields.length;
|
||||
if (!migratedTables.includes('ssh_data')) {
|
||||
migratedTables.push('ssh_data');
|
||||
if (!migratedTables.includes("ssh_data")) {
|
||||
migratedTables.push("ssh_data");
|
||||
}
|
||||
migrated = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Process ssh_credentials table
|
||||
const sshCredentialsRecords = db.prepare("SELECT * FROM ssh_credentials WHERE user_id = ?").all(userId);
|
||||
const sshCredentialsRecords = db
|
||||
.prepare("SELECT * FROM ssh_credentials WHERE user_id = ?")
|
||||
.all(userId);
|
||||
for (const record of sshCredentialsRecords) {
|
||||
const sensitiveFields = LazyFieldEncryption.getSensitiveFieldsForTable('ssh_credentials');
|
||||
const { updatedRecord, migratedFields, needsUpdate } = LazyFieldEncryption.migrateRecordSensitiveFields(
|
||||
record,
|
||||
sensitiveFields,
|
||||
userDataKey,
|
||||
record.id.toString()
|
||||
);
|
||||
const sensitiveFields =
|
||||
LazyFieldEncryption.getSensitiveFieldsForTable("ssh_credentials");
|
||||
const { updatedRecord, migratedFields, needsUpdate } =
|
||||
LazyFieldEncryption.migrateRecordSensitiveFields(
|
||||
record,
|
||||
sensitiveFields,
|
||||
userDataKey,
|
||||
record.id.toString(),
|
||||
);
|
||||
|
||||
if (needsUpdate) {
|
||||
// Update the record in database
|
||||
const updateQuery = `
|
||||
UPDATE ssh_credentials
|
||||
SET password = ?, key = ?, key_password = ?, private_key = ?, updated_at = CURRENT_TIMESTAMP
|
||||
@@ -176,30 +155,32 @@ class DataCrypto {
|
||||
updatedRecord.key || null,
|
||||
updatedRecord.key_password || null,
|
||||
updatedRecord.private_key || null,
|
||||
record.id
|
||||
record.id,
|
||||
);
|
||||
|
||||
migratedFieldsCount += migratedFields.length;
|
||||
if (!migratedTables.includes('ssh_credentials')) {
|
||||
migratedTables.push('ssh_credentials');
|
||||
if (!migratedTables.includes("ssh_credentials")) {
|
||||
migratedTables.push("ssh_credentials");
|
||||
}
|
||||
migrated = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Process users table
|
||||
const userRecord = db.prepare("SELECT * FROM users WHERE id = ?").get(userId);
|
||||
const userRecord = db
|
||||
.prepare("SELECT * FROM users WHERE id = ?")
|
||||
.get(userId);
|
||||
if (userRecord) {
|
||||
const sensitiveFields = LazyFieldEncryption.getSensitiveFieldsForTable('users');
|
||||
const { updatedRecord, migratedFields, needsUpdate } = LazyFieldEncryption.migrateRecordSensitiveFields(
|
||||
userRecord,
|
||||
sensitiveFields,
|
||||
userDataKey,
|
||||
userId
|
||||
);
|
||||
const sensitiveFields =
|
||||
LazyFieldEncryption.getSensitiveFieldsForTable("users");
|
||||
const { updatedRecord, migratedFields, needsUpdate } =
|
||||
LazyFieldEncryption.migrateRecordSensitiveFields(
|
||||
userRecord,
|
||||
sensitiveFields,
|
||||
userDataKey,
|
||||
userId,
|
||||
);
|
||||
|
||||
if (needsUpdate) {
|
||||
// Update the record in database
|
||||
const updateQuery = `
|
||||
UPDATE users
|
||||
SET totp_secret = ?, totp_backup_codes = ?
|
||||
@@ -208,28 +189,18 @@ class DataCrypto {
|
||||
db.prepare(updateQuery).run(
|
||||
updatedRecord.totp_secret || null,
|
||||
updatedRecord.totp_backup_codes || null,
|
||||
userId
|
||||
userId,
|
||||
);
|
||||
|
||||
migratedFieldsCount += migratedFields.length;
|
||||
if (!migratedTables.includes('users')) {
|
||||
migratedTables.push('users');
|
||||
if (!migratedTables.includes("users")) {
|
||||
migratedTables.push("users");
|
||||
}
|
||||
migrated = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (migrated) {
|
||||
databaseLogger.success("User sensitive fields migration completed", {
|
||||
operation: "user_sensitive_migration_success",
|
||||
userId,
|
||||
migratedTables,
|
||||
migratedFieldsCount,
|
||||
});
|
||||
}
|
||||
|
||||
return { migrated, migratedTables, migratedFieldsCount };
|
||||
|
||||
} catch (error) {
|
||||
databaseLogger.error("User sensitive fields migration failed", error, {
|
||||
operation: "user_sensitive_migration_failed",
|
||||
@@ -237,21 +208,14 @@ class DataCrypto {
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
|
||||
// Don't throw error to avoid breaking user login
|
||||
return { migrated: false, migratedTables: [], migratedFieldsCount: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user data key
|
||||
*/
|
||||
static getUserDataKey(userId: string): Buffer | null {
|
||||
return this.userCrypto.getUserDataKey(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify user access permissions - simple and direct
|
||||
*/
|
||||
static validateUserAccess(userId: string): Buffer {
|
||||
const userDataKey = this.getUserDataKey(userId);
|
||||
if (!userDataKey) {
|
||||
@@ -260,48 +224,55 @@ class DataCrypto {
|
||||
return userDataKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience method: automatically get user key and encrypt
|
||||
*/
|
||||
static encryptRecordForUser(tableName: string, record: any, userId: string): any {
|
||||
static encryptRecordForUser(
|
||||
tableName: string,
|
||||
record: any,
|
||||
userId: string,
|
||||
): any {
|
||||
const userDataKey = this.validateUserAccess(userId);
|
||||
return this.encryptRecord(tableName, record, userId, userDataKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience method: automatically get user key and decrypt
|
||||
*/
|
||||
static decryptRecordForUser(tableName: string, record: any, userId: string): any {
|
||||
static decryptRecordForUser(
|
||||
tableName: string,
|
||||
record: any,
|
||||
userId: string,
|
||||
): any {
|
||||
const userDataKey = this.validateUserAccess(userId);
|
||||
return this.decryptRecord(tableName, record, userId, userDataKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience method: batch decrypt
|
||||
*/
|
||||
static decryptRecordsForUser(tableName: string, records: any[], userId: string): any[] {
|
||||
static decryptRecordsForUser(
|
||||
tableName: string,
|
||||
records: any[],
|
||||
userId: string,
|
||||
): any[] {
|
||||
const userDataKey = this.validateUserAccess(userId);
|
||||
return this.decryptRecords(tableName, records, userId, userDataKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user can access data
|
||||
*/
|
||||
static canUserAccessData(userId: string): boolean {
|
||||
return this.userCrypto.isUserUnlocked(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test encryption functionality
|
||||
*/
|
||||
static testUserEncryption(userId: string): boolean {
|
||||
try {
|
||||
const userDataKey = this.getUserDataKey(userId);
|
||||
if (!userDataKey) return false;
|
||||
|
||||
const testData = "test-" + Date.now();
|
||||
const encrypted = FieldCrypto.encryptField(testData, userDataKey, "test-record", "test-field");
|
||||
const decrypted = FieldCrypto.decryptField(encrypted, userDataKey, "test-record", "test-field");
|
||||
const encrypted = FieldCrypto.encryptField(
|
||||
testData,
|
||||
userDataKey,
|
||||
"test-record",
|
||||
"test-field",
|
||||
);
|
||||
const decrypted = FieldCrypto.decryptField(
|
||||
encrypted,
|
||||
userDataKey,
|
||||
"test-record",
|
||||
"test-field",
|
||||
);
|
||||
|
||||
return decrypted === testData;
|
||||
} catch (error) {
|
||||
@@ -310,4 +281,4 @@ class DataCrypto {
|
||||
}
|
||||
}
|
||||
|
||||
export { DataCrypto };
|
||||
export { DataCrypto };
|
||||
|
||||
@@ -10,19 +10,10 @@ interface EncryptedFileMetadata {
|
||||
version: string;
|
||||
fingerprint: string;
|
||||
algorithm: string;
|
||||
keySource?: string; // Track where the key comes from (SystemCrypto) - v2 only
|
||||
salt?: string; // Legacy v1 format only
|
||||
keySource?: string;
|
||||
salt?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Database file encryption - encrypts the entire SQLite database file at rest
|
||||
* Uses SystemCrypto for key management - no more fixed seed garbage!
|
||||
*
|
||||
* Linus principles applied:
|
||||
* - Remove hardcoded keys security disaster
|
||||
* - Use SystemCrypto instance keys for proper per-instance security
|
||||
* - Simple and direct, no complex key derivation
|
||||
*/
|
||||
class DatabaseFileEncryption {
|
||||
private static readonly VERSION = "v2";
|
||||
private static readonly ALGORITHM = "aes-256-gcm";
|
||||
@@ -30,33 +21,28 @@ class DatabaseFileEncryption {
|
||||
private static readonly METADATA_FILE_SUFFIX = ".meta";
|
||||
private static systemCrypto = SystemCrypto.getInstance();
|
||||
|
||||
/**
|
||||
* Encrypt database from buffer (for in-memory databases)
|
||||
*/
|
||||
static async encryptDatabaseFromBuffer(buffer: Buffer, targetPath: string): Promise<string> {
|
||||
static async encryptDatabaseFromBuffer(
|
||||
buffer: Buffer,
|
||||
targetPath: string,
|
||||
): Promise<string> {
|
||||
try {
|
||||
// Get database key from SystemCrypto (no more fixed seed garbage!)
|
||||
const key = await this.systemCrypto.getDatabaseKey();
|
||||
|
||||
// Generate encryption components
|
||||
const iv = crypto.randomBytes(16);
|
||||
|
||||
// Encrypt the buffer
|
||||
const cipher = crypto.createCipheriv(this.ALGORITHM, key, iv) as any;
|
||||
const encrypted = Buffer.concat([cipher.update(buffer), cipher.final()]);
|
||||
const tag = cipher.getAuthTag();
|
||||
|
||||
// Create metadata
|
||||
const metadata: EncryptedFileMetadata = {
|
||||
iv: iv.toString("hex"),
|
||||
tag: tag.toString("hex"),
|
||||
version: this.VERSION,
|
||||
fingerprint: "termix-v2-systemcrypto", // SystemCrypto managed key
|
||||
fingerprint: "termix-v2-systemcrypto",
|
||||
algorithm: this.ALGORITHM,
|
||||
keySource: "SystemCrypto",
|
||||
};
|
||||
|
||||
// Write encrypted file and metadata
|
||||
const metadataPath = `${targetPath}${this.METADATA_FILE_SUFFIX}`;
|
||||
fs.writeFileSync(targetPath, encrypted);
|
||||
fs.writeFileSync(metadataPath, JSON.stringify(metadata, null, 2));
|
||||
@@ -73,10 +59,10 @@ class DatabaseFileEncryption {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt database file
|
||||
*/
|
||||
static async encryptDatabaseFile(sourcePath: string, targetPath?: string): Promise<string> {
|
||||
static async encryptDatabaseFile(
|
||||
sourcePath: string,
|
||||
targetPath?: string,
|
||||
): Promise<string> {
|
||||
if (!fs.existsSync(sourcePath)) {
|
||||
throw new Error(`Source database file does not exist: ${sourcePath}`);
|
||||
}
|
||||
@@ -86,16 +72,12 @@ class DatabaseFileEncryption {
|
||||
const metadataPath = `${encryptedPath}${this.METADATA_FILE_SUFFIX}`;
|
||||
|
||||
try {
|
||||
// Read source file
|
||||
const sourceData = fs.readFileSync(sourcePath);
|
||||
|
||||
// Get database key from SystemCrypto (no more fixed seed garbage!)
|
||||
const key = await this.systemCrypto.getDatabaseKey();
|
||||
|
||||
// Generate encryption components
|
||||
const iv = crypto.randomBytes(16);
|
||||
|
||||
// Encrypt the file
|
||||
const cipher = crypto.createCipheriv(this.ALGORITHM, key, iv) as any;
|
||||
const encrypted = Buffer.concat([
|
||||
cipher.update(sourceData),
|
||||
@@ -103,17 +85,15 @@ class DatabaseFileEncryption {
|
||||
]);
|
||||
const tag = cipher.getAuthTag();
|
||||
|
||||
// Create metadata
|
||||
const metadata: EncryptedFileMetadata = {
|
||||
iv: iv.toString("hex"),
|
||||
tag: tag.toString("hex"),
|
||||
version: this.VERSION,
|
||||
fingerprint: "termix-v2-systemcrypto", // SystemCrypto managed key
|
||||
fingerprint: "termix-v2-systemcrypto",
|
||||
algorithm: this.ALGORITHM,
|
||||
keySource: "SystemCrypto",
|
||||
};
|
||||
|
||||
// Write encrypted file and metadata
|
||||
fs.writeFileSync(encryptedPath, encrypted);
|
||||
fs.writeFileSync(metadataPath, JSON.stringify(metadata, null, 2));
|
||||
|
||||
@@ -139,9 +119,6 @@ class DatabaseFileEncryption {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt database file to buffer (for in-memory usage)
|
||||
*/
|
||||
static async decryptDatabaseToBuffer(encryptedPath: string): Promise<Buffer> {
|
||||
if (!fs.existsSync(encryptedPath)) {
|
||||
throw new Error(
|
||||
@@ -155,35 +132,33 @@ class DatabaseFileEncryption {
|
||||
}
|
||||
|
||||
try {
|
||||
// Read metadata
|
||||
const metadataContent = fs.readFileSync(metadataPath, "utf8");
|
||||
const metadata: EncryptedFileMetadata = JSON.parse(metadataContent);
|
||||
|
||||
// Read encrypted data
|
||||
const encryptedData = fs.readFileSync(encryptedPath);
|
||||
|
||||
// Get decryption key based on version
|
||||
let key: Buffer;
|
||||
if (metadata.version === "v2") {
|
||||
// New v2 format: use SystemCrypto key
|
||||
key = await this.systemCrypto.getDatabaseKey();
|
||||
} else if (metadata.version === "v1") {
|
||||
// Legacy v1 format: use deprecated salt-based key derivation
|
||||
databaseLogger.warn("Decrypting legacy v1 encrypted database - consider upgrading", {
|
||||
operation: "decrypt_legacy_v1",
|
||||
path: encryptedPath
|
||||
});
|
||||
databaseLogger.warn(
|
||||
"Decrypting legacy v1 encrypted database - consider upgrading",
|
||||
{
|
||||
operation: "decrypt_legacy_v1",
|
||||
path: encryptedPath,
|
||||
},
|
||||
);
|
||||
if (!metadata.salt) {
|
||||
throw new Error("v1 encrypted file missing required salt field");
|
||||
}
|
||||
const salt = Buffer.from(metadata.salt, "hex");
|
||||
const fixedSeed = process.env.DB_FILE_KEY || "termix-database-file-encryption-seed-v1";
|
||||
const fixedSeed =
|
||||
process.env.DB_FILE_KEY || "termix-database-file-encryption-seed-v1";
|
||||
key = crypto.pbkdf2Sync(fixedSeed, salt, 100000, 32, "sha256");
|
||||
} else {
|
||||
throw new Error(`Unsupported encryption version: ${metadata.version}`);
|
||||
}
|
||||
|
||||
// Decrypt to buffer
|
||||
const decipher = crypto.createDecipheriv(
|
||||
metadata.algorithm,
|
||||
key,
|
||||
@@ -208,9 +183,6 @@ class DatabaseFileEncryption {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt database file
|
||||
*/
|
||||
static async decryptDatabaseFile(
|
||||
encryptedPath: string,
|
||||
targetPath?: string,
|
||||
@@ -230,35 +202,33 @@ class DatabaseFileEncryption {
|
||||
targetPath || encryptedPath.replace(this.ENCRYPTED_FILE_SUFFIX, "");
|
||||
|
||||
try {
|
||||
// Read metadata
|
||||
const metadataContent = fs.readFileSync(metadataPath, "utf8");
|
||||
const metadata: EncryptedFileMetadata = JSON.parse(metadataContent);
|
||||
|
||||
// Read encrypted data
|
||||
const encryptedData = fs.readFileSync(encryptedPath);
|
||||
|
||||
// Get decryption key based on version
|
||||
let key: Buffer;
|
||||
if (metadata.version === "v2") {
|
||||
// New v2 format: use SystemCrypto key
|
||||
key = await this.systemCrypto.getDatabaseKey();
|
||||
} else if (metadata.version === "v1") {
|
||||
// Legacy v1 format: use deprecated salt-based key derivation
|
||||
databaseLogger.warn("Decrypting legacy v1 encrypted database - consider upgrading", {
|
||||
operation: "decrypt_legacy_v1",
|
||||
path: encryptedPath
|
||||
});
|
||||
databaseLogger.warn(
|
||||
"Decrypting legacy v1 encrypted database - consider upgrading",
|
||||
{
|
||||
operation: "decrypt_legacy_v1",
|
||||
path: encryptedPath,
|
||||
},
|
||||
);
|
||||
if (!metadata.salt) {
|
||||
throw new Error("v1 encrypted file missing required salt field");
|
||||
}
|
||||
const salt = Buffer.from(metadata.salt, "hex");
|
||||
const fixedSeed = process.env.DB_FILE_KEY || "termix-database-file-encryption-seed-v1";
|
||||
const fixedSeed =
|
||||
process.env.DB_FILE_KEY || "termix-database-file-encryption-seed-v1";
|
||||
key = crypto.pbkdf2Sync(fixedSeed, salt, 100000, 32, "sha256");
|
||||
} else {
|
||||
throw new Error(`Unsupported encryption version: ${metadata.version}`);
|
||||
}
|
||||
|
||||
// Decrypt the file
|
||||
const decipher = crypto.createDecipheriv(
|
||||
metadata.algorithm,
|
||||
key,
|
||||
@@ -271,7 +241,6 @@ class DatabaseFileEncryption {
|
||||
decipher.final(),
|
||||
]);
|
||||
|
||||
// Write decrypted file
|
||||
fs.writeFileSync(decryptedPath, decrypted);
|
||||
|
||||
databaseLogger.info("Database file decrypted successfully", {
|
||||
@@ -296,9 +265,6 @@ class DatabaseFileEncryption {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a file is an encrypted database file
|
||||
*/
|
||||
static isEncryptedDatabaseFile(filePath: string): boolean {
|
||||
const metadataPath = `${filePath}${this.METADATA_FILE_SUFFIX}`;
|
||||
|
||||
@@ -318,9 +284,6 @@ class DatabaseFileEncryption {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get information about an encrypted database file
|
||||
*/
|
||||
static getEncryptedFileInfo(encryptedPath: string): {
|
||||
version: string;
|
||||
algorithm: string;
|
||||
@@ -338,13 +301,13 @@ class DatabaseFileEncryption {
|
||||
const metadata: EncryptedFileMetadata = JSON.parse(metadataContent);
|
||||
|
||||
const fileStats = fs.statSync(encryptedPath);
|
||||
const currentFingerprint = "termix-v1-file"; // Fixed identifier
|
||||
const currentFingerprint = "termix-v1-file";
|
||||
|
||||
return {
|
||||
version: metadata.version,
|
||||
algorithm: metadata.algorithm,
|
||||
fingerprint: metadata.fingerprint,
|
||||
isCurrentHardware: true, // Hardware validation removed
|
||||
isCurrentHardware: true,
|
||||
fileSize: fileStats.size,
|
||||
};
|
||||
} catch {
|
||||
@@ -352,9 +315,6 @@ class DatabaseFileEncryption {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Securely backup database by creating encrypted copy
|
||||
*/
|
||||
static async createEncryptedBackup(
|
||||
databasePath: string,
|
||||
backupDir: string,
|
||||
@@ -363,25 +323,19 @@ class DatabaseFileEncryption {
|
||||
throw new Error(`Database file does not exist: ${databasePath}`);
|
||||
}
|
||||
|
||||
// Ensure backup directory exists
|
||||
if (!fs.existsSync(backupDir)) {
|
||||
fs.mkdirSync(backupDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Generate backup filename with timestamp
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||
const backupFileName = `database-backup-${timestamp}.sqlite.encrypted`;
|
||||
const backupPath = path.join(backupDir, backupFileName);
|
||||
|
||||
try {
|
||||
const encryptedPath = await this.encryptDatabaseFile(databasePath, backupPath);
|
||||
|
||||
databaseLogger.info("Encrypted database backup created", {
|
||||
operation: "database_backup",
|
||||
sourcePath: databasePath,
|
||||
backupPath: encryptedPath,
|
||||
timestamp,
|
||||
});
|
||||
const encryptedPath = await this.encryptDatabaseFile(
|
||||
databasePath,
|
||||
backupPath,
|
||||
);
|
||||
|
||||
return encryptedPath;
|
||||
} catch (error) {
|
||||
@@ -394,9 +348,6 @@ class DatabaseFileEncryption {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore database from encrypted backup
|
||||
*/
|
||||
static async restoreFromEncryptedBackup(
|
||||
backupPath: string,
|
||||
targetPath: string,
|
||||
@@ -406,13 +357,10 @@ class DatabaseFileEncryption {
|
||||
}
|
||||
|
||||
try {
|
||||
const restoredPath = await this.decryptDatabaseFile(backupPath, targetPath);
|
||||
|
||||
databaseLogger.info("Database restored from encrypted backup", {
|
||||
operation: "database_restore",
|
||||
const restoredPath = await this.decryptDatabaseFile(
|
||||
backupPath,
|
||||
restoredPath,
|
||||
});
|
||||
targetPath,
|
||||
);
|
||||
|
||||
return restoredPath;
|
||||
} catch (error) {
|
||||
@@ -425,10 +373,6 @@ class DatabaseFileEncryption {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Clean up temporary files
|
||||
*/
|
||||
static cleanupTempFiles(basePath: string): void {
|
||||
try {
|
||||
const tempFiles = [
|
||||
|
||||
@@ -32,12 +32,11 @@ export class DatabaseMigration {
|
||||
this.encryptedDbPath = `${this.unencryptedDbPath}.encrypted`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否需要迁移以及迁移状态
|
||||
*/
|
||||
checkMigrationStatus(): MigrationStatus {
|
||||
const hasUnencryptedDb = fs.existsSync(this.unencryptedDbPath);
|
||||
const hasEncryptedDb = DatabaseFileEncryption.isEncryptedDatabaseFile(this.encryptedDbPath);
|
||||
const hasEncryptedDb = DatabaseFileEncryption.isEncryptedDatabaseFile(
|
||||
this.encryptedDbPath,
|
||||
);
|
||||
|
||||
let unencryptedDbSize = 0;
|
||||
if (hasUnencryptedDb) {
|
||||
@@ -51,24 +50,21 @@ export class DatabaseMigration {
|
||||
}
|
||||
}
|
||||
|
||||
// 确定迁移状态
|
||||
let needsMigration = false;
|
||||
let reason = "";
|
||||
|
||||
if (hasEncryptedDb && hasUnencryptedDb) {
|
||||
// 两个都存在:可能是之前迁移失败或中断
|
||||
needsMigration = false;
|
||||
reason = "Both encrypted and unencrypted databases exist. Skipping migration for safety. Manual intervention may be required.";
|
||||
reason =
|
||||
"Both encrypted and unencrypted databases exist. Skipping migration for safety. Manual intervention may be required.";
|
||||
} else if (hasEncryptedDb && !hasUnencryptedDb) {
|
||||
// 只有加密数据库:无需迁移
|
||||
needsMigration = false;
|
||||
reason = "Only encrypted database exists. No migration needed.";
|
||||
} else if (!hasEncryptedDb && hasUnencryptedDb) {
|
||||
// 只有未加密数据库:需要迁移
|
||||
needsMigration = true;
|
||||
reason = "Unencrypted database found. Migration to encrypted format required.";
|
||||
reason =
|
||||
"Unencrypted database found. Migration to encrypted format required.";
|
||||
} else {
|
||||
// 都不存在:全新安装
|
||||
needsMigration = false;
|
||||
reason = "No existing database found. This is a fresh installation.";
|
||||
}
|
||||
@@ -82,36 +78,22 @@ export class DatabaseMigration {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建未加密数据库的安全备份
|
||||
*/
|
||||
private createBackup(): string {
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||
const backupPath = `${this.unencryptedDbPath}.migration-backup-${timestamp}`;
|
||||
|
||||
try {
|
||||
databaseLogger.info("Creating migration backup", {
|
||||
operation: "migration_backup_create",
|
||||
source: this.unencryptedDbPath,
|
||||
backup: backupPath,
|
||||
});
|
||||
|
||||
fs.copyFileSync(this.unencryptedDbPath, backupPath);
|
||||
|
||||
// 验证备份完整性
|
||||
const originalSize = fs.statSync(this.unencryptedDbPath).size;
|
||||
const backupSize = fs.statSync(backupPath).size;
|
||||
|
||||
if (originalSize !== backupSize) {
|
||||
throw new Error(`Backup size mismatch: original=${originalSize}, backup=${backupSize}`);
|
||||
throw new Error(
|
||||
`Backup size mismatch: original=${originalSize}, backup=${backupSize}`,
|
||||
);
|
||||
}
|
||||
|
||||
databaseLogger.success("Migration backup created successfully", {
|
||||
operation: "migration_backup_created",
|
||||
backupPath,
|
||||
fileSize: backupSize,
|
||||
});
|
||||
|
||||
return backupPath;
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to create migration backup", error, {
|
||||
@@ -119,79 +101,81 @@ export class DatabaseMigration {
|
||||
source: this.unencryptedDbPath,
|
||||
backup: backupPath,
|
||||
});
|
||||
throw new Error(`Backup creation failed: ${error instanceof Error ? error.message : "Unknown error"}`);
|
||||
throw new Error(
|
||||
`Backup creation failed: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证数据库迁移的完整性
|
||||
*/
|
||||
private async verifyMigration(originalDb: Database.Database, memoryDb: Database.Database): Promise<boolean> {
|
||||
private async verifyMigration(
|
||||
originalDb: Database.Database,
|
||||
memoryDb: Database.Database,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
databaseLogger.info("Verifying migration integrity", {
|
||||
operation: "migration_verify_start",
|
||||
});
|
||||
|
||||
// 临时禁用外键约束以进行验证查询
|
||||
memoryDb.exec("PRAGMA foreign_keys = OFF");
|
||||
|
||||
// 获取原数据库的表列表
|
||||
const originalTables = originalDb
|
||||
.prepare(`
|
||||
.prepare(
|
||||
`
|
||||
SELECT name FROM sqlite_master
|
||||
WHERE type='table' AND name NOT LIKE 'sqlite_%'
|
||||
ORDER BY name
|
||||
`)
|
||||
`,
|
||||
)
|
||||
.all() as { name: string }[];
|
||||
|
||||
// 获取内存数据库的表列表
|
||||
const memoryTables = memoryDb
|
||||
.prepare(`
|
||||
.prepare(
|
||||
`
|
||||
SELECT name FROM sqlite_master
|
||||
WHERE type='table' AND name NOT LIKE 'sqlite_%'
|
||||
ORDER BY name
|
||||
`)
|
||||
`,
|
||||
)
|
||||
.all() as { name: string }[];
|
||||
|
||||
// 检查表数量是否一致
|
||||
if (originalTables.length !== memoryTables.length) {
|
||||
databaseLogger.error("Table count mismatch during migration verification", null, {
|
||||
operation: "migration_verify_failed",
|
||||
originalCount: originalTables.length,
|
||||
memoryCount: memoryTables.length,
|
||||
});
|
||||
databaseLogger.error(
|
||||
"Table count mismatch during migration verification",
|
||||
null,
|
||||
{
|
||||
operation: "migration_verify_failed",
|
||||
originalCount: originalTables.length,
|
||||
memoryCount: memoryTables.length,
|
||||
},
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
let totalOriginalRows = 0;
|
||||
let totalMemoryRows = 0;
|
||||
|
||||
// 逐表验证行数
|
||||
for (const table of originalTables) {
|
||||
const originalCount = originalDb.prepare(`SELECT COUNT(*) as count FROM ${table.name}`).get() as { count: number };
|
||||
const memoryCount = memoryDb.prepare(`SELECT COUNT(*) as count FROM ${table.name}`).get() as { count: number };
|
||||
const originalCount = originalDb
|
||||
.prepare(`SELECT COUNT(*) as count FROM ${table.name}`)
|
||||
.get() as { count: number };
|
||||
const memoryCount = memoryDb
|
||||
.prepare(`SELECT COUNT(*) as count FROM ${table.name}`)
|
||||
.get() as { count: number };
|
||||
|
||||
totalOriginalRows += originalCount.count;
|
||||
totalMemoryRows += memoryCount.count;
|
||||
|
||||
if (originalCount.count !== memoryCount.count) {
|
||||
databaseLogger.error("Row count mismatch for table during migration verification", null, {
|
||||
operation: "migration_verify_table_failed",
|
||||
table: table.name,
|
||||
originalRows: originalCount.count,
|
||||
memoryRows: memoryCount.count,
|
||||
});
|
||||
databaseLogger.error(
|
||||
"Row count mismatch for table during migration verification",
|
||||
null,
|
||||
{
|
||||
operation: "migration_verify_table_failed",
|
||||
table: table.name,
|
||||
originalRows: originalCount.count,
|
||||
memoryRows: memoryCount.count,
|
||||
},
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
databaseLogger.success("Migration integrity verification completed", {
|
||||
operation: "migration_verify_success",
|
||||
tables: originalTables.length,
|
||||
totalRows: totalOriginalRows,
|
||||
});
|
||||
|
||||
// 重新启用外键约束
|
||||
memoryDb.exec("PRAGMA foreign_keys = ON");
|
||||
|
||||
return true;
|
||||
@@ -203,9 +187,6 @@ export class DatabaseMigration {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行数据库迁移
|
||||
*/
|
||||
async migrateDatabase(): Promise<MigrationResult> {
|
||||
const startTime = Date.now();
|
||||
let backupPath: string | undefined;
|
||||
@@ -213,49 +194,31 @@ export class DatabaseMigration {
|
||||
let migratedRows = 0;
|
||||
|
||||
try {
|
||||
databaseLogger.info("Starting database migration from unencrypted to encrypted format", {
|
||||
operation: "migration_start",
|
||||
source: this.unencryptedDbPath,
|
||||
target: this.encryptedDbPath,
|
||||
});
|
||||
|
||||
// 1. 创建安全备份
|
||||
backupPath = this.createBackup();
|
||||
|
||||
// 2. 打开原数据库(只读)
|
||||
const originalDb = new Database(this.unencryptedDbPath, { readonly: true });
|
||||
const originalDb = new Database(this.unencryptedDbPath, {
|
||||
readonly: true,
|
||||
});
|
||||
|
||||
// 3. 创建内存数据库
|
||||
const memoryDb = new Database(":memory:");
|
||||
|
||||
try {
|
||||
// 4. 获取所有表结构
|
||||
const tables = originalDb
|
||||
.prepare(`
|
||||
.prepare(
|
||||
`
|
||||
SELECT name, sql FROM sqlite_master
|
||||
WHERE type='table' AND name NOT LIKE 'sqlite_%'
|
||||
`)
|
||||
`,
|
||||
)
|
||||
.all() as { name: string; sql: string }[];
|
||||
|
||||
databaseLogger.info("Found tables to migrate", {
|
||||
operation: "migration_tables_found",
|
||||
tableCount: tables.length,
|
||||
tables: tables.map(t => t.name),
|
||||
});
|
||||
|
||||
// 5. 在内存数据库中创建表结构
|
||||
for (const table of tables) {
|
||||
memoryDb.exec(table.sql);
|
||||
migratedTables++;
|
||||
}
|
||||
|
||||
// 6. 禁用外键约束以避免插入顺序问题
|
||||
databaseLogger.info("Disabling foreign key constraints for migration", {
|
||||
operation: "migration_disable_fk",
|
||||
});
|
||||
memoryDb.exec("PRAGMA foreign_keys = OFF");
|
||||
|
||||
// 7. 复制每个表的数据
|
||||
for (const table of tables) {
|
||||
const rows = originalDb.prepare(`SELECT * FROM ${table.name}`).all();
|
||||
|
||||
@@ -263,66 +226,64 @@ export class DatabaseMigration {
|
||||
const columns = Object.keys(rows[0]);
|
||||
const placeholders = columns.map(() => "?").join(", ");
|
||||
const insertStmt = memoryDb.prepare(
|
||||
`INSERT INTO ${table.name} (${columns.join(", ")}) VALUES (${placeholders})`
|
||||
`INSERT INTO ${table.name} (${columns.join(", ")}) VALUES (${placeholders})`,
|
||||
);
|
||||
|
||||
// 使用事务批量插入
|
||||
const insertTransaction = memoryDb.transaction((dataRows: any[]) => {
|
||||
for (const row of dataRows) {
|
||||
const values = columns.map((col) => row[col]);
|
||||
insertStmt.run(values);
|
||||
}
|
||||
});
|
||||
const insertTransaction = memoryDb.transaction(
|
||||
(dataRows: any[]) => {
|
||||
for (const row of dataRows) {
|
||||
const values = columns.map((col) => row[col]);
|
||||
insertStmt.run(values);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
insertTransaction(rows);
|
||||
migratedRows += rows.length;
|
||||
}
|
||||
}
|
||||
|
||||
// 8. 重新启用外键约束
|
||||
databaseLogger.info("Re-enabling foreign key constraints after migration", {
|
||||
operation: "migration_enable_fk",
|
||||
});
|
||||
memoryDb.exec("PRAGMA foreign_keys = ON");
|
||||
|
||||
// 验证外键约束现在是否正常
|
||||
const fkCheckResult = memoryDb.prepare("PRAGMA foreign_key_check").all();
|
||||
const fkCheckResult = memoryDb
|
||||
.prepare("PRAGMA foreign_key_check")
|
||||
.all();
|
||||
if (fkCheckResult.length > 0) {
|
||||
databaseLogger.error("Foreign key constraints violations detected after migration", null, {
|
||||
operation: "migration_fk_check_failed",
|
||||
violations: fkCheckResult,
|
||||
});
|
||||
throw new Error(`Foreign key violations detected: ${JSON.stringify(fkCheckResult)}`);
|
||||
databaseLogger.error(
|
||||
"Foreign key constraints violations detected after migration",
|
||||
null,
|
||||
{
|
||||
operation: "migration_fk_check_failed",
|
||||
violations: fkCheckResult,
|
||||
},
|
||||
);
|
||||
throw new Error(
|
||||
`Foreign key violations detected: ${JSON.stringify(fkCheckResult)}`,
|
||||
);
|
||||
}
|
||||
|
||||
databaseLogger.success("Foreign key constraints verification passed", {
|
||||
operation: "migration_fk_check_success",
|
||||
});
|
||||
|
||||
// 9. 验证迁移完整性
|
||||
const verificationPassed = await this.verifyMigration(originalDb, memoryDb);
|
||||
const verificationPassed = await this.verifyMigration(
|
||||
originalDb,
|
||||
memoryDb,
|
||||
);
|
||||
if (!verificationPassed) {
|
||||
throw new Error("Migration integrity verification failed");
|
||||
}
|
||||
|
||||
// 10. 导出内存数据库到缓冲区
|
||||
const buffer = memoryDb.serialize();
|
||||
|
||||
// 11. 创建加密数据库文件
|
||||
databaseLogger.info("Creating encrypted database file", {
|
||||
operation: "migration_encrypt_start",
|
||||
bufferSize: buffer.length,
|
||||
});
|
||||
await DatabaseFileEncryption.encryptDatabaseFromBuffer(
|
||||
buffer,
|
||||
this.encryptedDbPath,
|
||||
);
|
||||
|
||||
await DatabaseFileEncryption.encryptDatabaseFromBuffer(buffer, this.encryptedDbPath);
|
||||
|
||||
// 12. 验证加密文件
|
||||
if (!DatabaseFileEncryption.isEncryptedDatabaseFile(this.encryptedDbPath)) {
|
||||
if (
|
||||
!DatabaseFileEncryption.isEncryptedDatabaseFile(this.encryptedDbPath)
|
||||
) {
|
||||
throw new Error("Encrypted database file verification failed");
|
||||
}
|
||||
|
||||
// 13. 清理:重命名原文件而不是删除
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||
const migratedPath = `${this.unencryptedDbPath}.migrated-${timestamp}`;
|
||||
|
||||
fs.renameSync(this.unencryptedDbPath, migratedPath);
|
||||
@@ -344,15 +305,13 @@ export class DatabaseMigration {
|
||||
backupPath,
|
||||
duration: Date.now() - startTime,
|
||||
};
|
||||
|
||||
} finally {
|
||||
// 确保数据库连接关闭
|
||||
originalDb.close();
|
||||
memoryDb.close();
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : "Unknown error";
|
||||
|
||||
databaseLogger.error("Database migration failed", error, {
|
||||
operation: "migration_failed",
|
||||
@@ -373,34 +332,33 @@ export class DatabaseMigration {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理旧的备份文件(保留最近3个)
|
||||
*/
|
||||
cleanupOldBackups(): void {
|
||||
try {
|
||||
const backupPattern = /\.migration-backup-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z$/;
|
||||
const migratedPattern = /\.migrated-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z$/;
|
||||
const backupPattern =
|
||||
/\.migration-backup-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z$/;
|
||||
const migratedPattern =
|
||||
/\.migrated-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z$/;
|
||||
|
||||
const files = fs.readdirSync(this.dataDir);
|
||||
|
||||
// 查找备份文件和已迁移文件
|
||||
const backupFiles = files.filter(f => backupPattern.test(f))
|
||||
.map(f => ({
|
||||
const backupFiles = files
|
||||
.filter((f) => backupPattern.test(f))
|
||||
.map((f) => ({
|
||||
name: f,
|
||||
path: path.join(this.dataDir, f),
|
||||
mtime: fs.statSync(path.join(this.dataDir, f)).mtime,
|
||||
}))
|
||||
.sort((a, b) => b.mtime.getTime() - a.mtime.getTime());
|
||||
|
||||
const migratedFiles = files.filter(f => migratedPattern.test(f))
|
||||
.map(f => ({
|
||||
const migratedFiles = files
|
||||
.filter((f) => migratedPattern.test(f))
|
||||
.map((f) => ({
|
||||
name: f,
|
||||
path: path.join(this.dataDir, f),
|
||||
mtime: fs.statSync(path.join(this.dataDir, f)).mtime,
|
||||
}))
|
||||
.sort((a, b) => b.mtime.getTime() - a.mtime.getTime());
|
||||
|
||||
// 保留最近3个备份文件
|
||||
const backupsToDelete = backupFiles.slice(3);
|
||||
const migratedToDelete = migratedFiles.slice(3);
|
||||
|
||||
@@ -415,17 +373,6 @@ export class DatabaseMigration {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (backupsToDelete.length > 0 || migratedToDelete.length > 0) {
|
||||
databaseLogger.info("Migration cleanup completed", {
|
||||
operation: "migration_cleanup_complete",
|
||||
deletedBackups: backupsToDelete.length,
|
||||
deletedMigrated: migratedToDelete.length,
|
||||
remainingBackups: Math.min(backupFiles.length, 3),
|
||||
remainingMigrated: Math.min(migratedFiles.length, 3),
|
||||
});
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
databaseLogger.warn("Migration cleanup failed", {
|
||||
operation: "migration_cleanup_error",
|
||||
@@ -433,4 +380,4 @@ export class DatabaseMigration {
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,31 +1,19 @@
|
||||
import { databaseLogger } from "./logger.js";
|
||||
|
||||
/**
|
||||
* Database Save Trigger - 自动触发内存数据库保存到磁盘
|
||||
* 确保数据修改后能持久化保存
|
||||
*/
|
||||
export class DatabaseSaveTrigger {
|
||||
private static saveFunction: (() => Promise<void>) | null = null;
|
||||
private static isInitialized = false;
|
||||
private static pendingSave = false;
|
||||
private static saveTimeout: NodeJS.Timeout | null = null;
|
||||
|
||||
/**
|
||||
* 初始化保存触发器
|
||||
*/
|
||||
static initialize(saveFunction: () => Promise<void>): void {
|
||||
this.saveFunction = saveFunction;
|
||||
this.isInitialized = true;
|
||||
|
||||
databaseLogger.info("Database save trigger initialized", {
|
||||
operation: "db_save_trigger_init",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 触发数据库保存 - 防抖处理,避免频繁保存
|
||||
*/
|
||||
static async triggerSave(reason: string = "data_modification"): Promise<void> {
|
||||
static async triggerSave(
|
||||
reason: string = "data_modification",
|
||||
): Promise<void> {
|
||||
if (!this.isInitialized || !this.saveFunction) {
|
||||
databaseLogger.warn("Database save trigger not initialized", {
|
||||
operation: "db_save_trigger_not_init",
|
||||
@@ -34,12 +22,10 @@ export class DatabaseSaveTrigger {
|
||||
return;
|
||||
}
|
||||
|
||||
// 清除之前的定时器
|
||||
if (this.saveTimeout) {
|
||||
clearTimeout(this.saveTimeout);
|
||||
}
|
||||
|
||||
// 防抖:延迟2秒执行,如果2秒内有新的保存请求,则重新计时
|
||||
this.saveTimeout = setTimeout(async () => {
|
||||
if (this.pendingSave) {
|
||||
return;
|
||||
@@ -58,22 +44,21 @@ export class DatabaseSaveTrigger {
|
||||
} finally {
|
||||
this.pendingSave = false;
|
||||
}
|
||||
}, 2000); // 2秒防抖
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
/**
|
||||
* 立即保存 - 用于关键操作
|
||||
*/
|
||||
static async forceSave(reason: string = "critical_operation"): Promise<void> {
|
||||
if (!this.isInitialized || !this.saveFunction) {
|
||||
databaseLogger.warn("Database save trigger not initialized for force save", {
|
||||
operation: "db_save_trigger_force_not_init",
|
||||
reason,
|
||||
});
|
||||
databaseLogger.warn(
|
||||
"Database save trigger not initialized for force save",
|
||||
{
|
||||
operation: "db_save_trigger_force_not_init",
|
||||
reason,
|
||||
},
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// 清除防抖定时器
|
||||
if (this.saveTimeout) {
|
||||
clearTimeout(this.saveTimeout);
|
||||
this.saveTimeout = null;
|
||||
@@ -92,26 +77,18 @@ export class DatabaseSaveTrigger {
|
||||
});
|
||||
|
||||
await this.saveFunction();
|
||||
|
||||
databaseLogger.success("Database force save completed", {
|
||||
operation: "db_save_trigger_force_success",
|
||||
reason,
|
||||
});
|
||||
} catch (error) {
|
||||
databaseLogger.error("Database force save failed", error, {
|
||||
operation: "db_save_trigger_force_failed",
|
||||
reason,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
throw error; // 重新抛出错误,因为这是强制保存
|
||||
throw error;
|
||||
} finally {
|
||||
this.pendingSave = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取保存状态
|
||||
*/
|
||||
static getStatus(): {
|
||||
initialized: boolean;
|
||||
pendingSave: boolean;
|
||||
@@ -124,9 +101,6 @@ export class DatabaseSaveTrigger {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理资源
|
||||
*/
|
||||
static cleanup(): void {
|
||||
if (this.saveTimeout) {
|
||||
clearTimeout(this.saveTimeout);
|
||||
@@ -141,4 +115,4 @@ export class DatabaseSaveTrigger {
|
||||
operation: "db_save_trigger_cleanup",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,40 +5,46 @@ interface EncryptedData {
|
||||
iv: string;
|
||||
tag: string;
|
||||
salt: string;
|
||||
recordId: string; // Store the recordId used for encryption context
|
||||
recordId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* FieldCrypto - Simple direct field encryption
|
||||
*
|
||||
* Linus principles:
|
||||
* - No special cases
|
||||
* - No compatibility checks
|
||||
* - Data is either encrypted or fails
|
||||
* - No "legacy data" concept
|
||||
*/
|
||||
class FieldCrypto {
|
||||
private static readonly ALGORITHM = "aes-256-gcm";
|
||||
private static readonly KEY_LENGTH = 32;
|
||||
private static readonly IV_LENGTH = 16;
|
||||
private static readonly SALT_LENGTH = 32;
|
||||
|
||||
// Fields requiring encryption - simple mapping, no complex logic
|
||||
private static readonly ENCRYPTED_FIELDS = {
|
||||
users: new Set(["password_hash", "client_secret", "totp_secret", "totp_backup_codes", "oidc_identifier"]),
|
||||
users: new Set([
|
||||
"password_hash",
|
||||
"client_secret",
|
||||
"totp_secret",
|
||||
"totp_backup_codes",
|
||||
"oidc_identifier",
|
||||
]),
|
||||
ssh_data: new Set(["password", "key", "keyPassword"]),
|
||||
ssh_credentials: new Set(["password", "privateKey", "keyPassword", "key", "publicKey"]),
|
||||
ssh_credentials: new Set([
|
||||
"password",
|
||||
"privateKey",
|
||||
"keyPassword",
|
||||
"key",
|
||||
"publicKey",
|
||||
]),
|
||||
};
|
||||
|
||||
/**
|
||||
* Encrypt field - no special cases
|
||||
*/
|
||||
static encryptField(plaintext: string, masterKey: Buffer, recordId: string, fieldName: string): string {
|
||||
static encryptField(
|
||||
plaintext: string,
|
||||
masterKey: Buffer,
|
||||
recordId: string,
|
||||
fieldName: string,
|
||||
): string {
|
||||
if (!plaintext) return "";
|
||||
|
||||
const salt = crypto.randomBytes(this.SALT_LENGTH);
|
||||
const context = `${recordId}:${fieldName}`;
|
||||
const fieldKey = Buffer.from(crypto.hkdfSync('sha256', masterKey, salt, context, this.KEY_LENGTH));
|
||||
const fieldKey = Buffer.from(
|
||||
crypto.hkdfSync("sha256", masterKey, salt, context, this.KEY_LENGTH),
|
||||
);
|
||||
|
||||
const iv = crypto.randomBytes(this.IV_LENGTH);
|
||||
const cipher = crypto.createCipheriv(this.ALGORITHM, fieldKey, iv) as any;
|
||||
@@ -52,29 +58,38 @@ class FieldCrypto {
|
||||
iv: iv.toString("hex"),
|
||||
tag: tag.toString("hex"),
|
||||
salt: salt.toString("hex"),
|
||||
recordId: recordId, // Store recordId for consistent decryption context
|
||||
recordId: recordId,
|
||||
};
|
||||
|
||||
return JSON.stringify(encryptedData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt field - either succeeds or fails, no third option
|
||||
*/
|
||||
static decryptField(encryptedValue: string, masterKey: Buffer, recordId: string, fieldName: string): string {
|
||||
static decryptField(
|
||||
encryptedValue: string,
|
||||
masterKey: Buffer,
|
||||
recordId: string,
|
||||
fieldName: string,
|
||||
): string {
|
||||
if (!encryptedValue) return "";
|
||||
|
||||
const encrypted: EncryptedData = JSON.parse(encryptedValue);
|
||||
const salt = Buffer.from(encrypted.salt, "hex");
|
||||
|
||||
// Use ONLY the recordId that was stored during encryption
|
||||
if (!encrypted.recordId) {
|
||||
throw new Error(`Encrypted field missing recordId context - data corruption or legacy format not supported`);
|
||||
throw new Error(
|
||||
`Encrypted field missing recordId context - data corruption or legacy format not supported`,
|
||||
);
|
||||
}
|
||||
const context = `${encrypted.recordId}:${fieldName}`;
|
||||
const fieldKey = Buffer.from(crypto.hkdfSync('sha256', masterKey, salt, context, this.KEY_LENGTH));
|
||||
const fieldKey = Buffer.from(
|
||||
crypto.hkdfSync("sha256", masterKey, salt, context, this.KEY_LENGTH),
|
||||
);
|
||||
|
||||
const decipher = crypto.createDecipheriv(this.ALGORITHM, fieldKey, Buffer.from(encrypted.iv, "hex")) as any;
|
||||
const decipher = crypto.createDecipheriv(
|
||||
this.ALGORITHM,
|
||||
fieldKey,
|
||||
Buffer.from(encrypted.iv, "hex"),
|
||||
) as any;
|
||||
decipher.setAuthTag(Buffer.from(encrypted.tag, "hex"));
|
||||
|
||||
let decrypted = decipher.update(encrypted.data, "hex", "utf8");
|
||||
@@ -83,13 +98,11 @@ class FieldCrypto {
|
||||
return decrypted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if field needs encryption - simple table lookup, no complex logic
|
||||
*/
|
||||
static shouldEncryptField(tableName: string, fieldName: string): boolean {
|
||||
const fields = this.ENCRYPTED_FIELDS[tableName as keyof typeof this.ENCRYPTED_FIELDS];
|
||||
const fields =
|
||||
this.ENCRYPTED_FIELDS[tableName as keyof typeof this.ENCRYPTED_FIELDS];
|
||||
return fields ? fields.has(fieldName) : false;
|
||||
}
|
||||
}
|
||||
|
||||
export { FieldCrypto, type EncryptedData };
|
||||
export { FieldCrypto, type EncryptedData };
|
||||
|
||||
@@ -1,51 +1,47 @@
|
||||
import { FieldCrypto } from "./field-crypto.js";
|
||||
import { databaseLogger } from "./logger.js";
|
||||
|
||||
/**
|
||||
* 延迟字段加密 - 处理从明文到加密的平滑迁移
|
||||
* 用于在用户登录时将明文敏感数据逐步加密
|
||||
*/
|
||||
export class LazyFieldEncryption {
|
||||
/**
|
||||
* 检测字段是否为明文(未加密)
|
||||
*/
|
||||
static isPlaintextField(value: string): boolean {
|
||||
if (!value) return false;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
// 如果能解析为JSON且包含加密数据结构,则认为已加密
|
||||
if (parsed && typeof parsed === 'object' &&
|
||||
parsed.data && parsed.iv && parsed.tag && parsed.salt && parsed.recordId) {
|
||||
return false; // 已加密
|
||||
if (
|
||||
parsed &&
|
||||
typeof parsed === "object" &&
|
||||
parsed.data &&
|
||||
parsed.iv &&
|
||||
parsed.tag &&
|
||||
parsed.salt &&
|
||||
parsed.recordId
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
// JSON格式但不是加密结构,视为明文
|
||||
return true;
|
||||
} catch (jsonError) {
|
||||
// 无法解析为JSON,视为明文
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 安全获取字段值 - 自动处理明文和加密数据
|
||||
* 如果是明文,直接返回;如果已加密,则解密
|
||||
*/
|
||||
static safeGetFieldValue(
|
||||
fieldValue: string,
|
||||
userKEK: Buffer,
|
||||
recordId: string,
|
||||
fieldName: string
|
||||
fieldName: string,
|
||||
): string {
|
||||
if (!fieldValue) return "";
|
||||
|
||||
if (this.isPlaintextField(fieldValue)) {
|
||||
// 明文数据,直接返回
|
||||
return fieldValue;
|
||||
} else {
|
||||
// 加密数据,需要解密
|
||||
try {
|
||||
const decrypted = FieldCrypto.decryptField(fieldValue, userKEK, recordId, fieldName);
|
||||
const decrypted = FieldCrypto.decryptField(
|
||||
fieldValue,
|
||||
userKEK,
|
||||
recordId,
|
||||
fieldName,
|
||||
);
|
||||
return decrypted;
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to decrypt field", error, {
|
||||
@@ -59,31 +55,24 @@ export class LazyFieldEncryption {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 迁移明文字段到加密状态
|
||||
* 返回加密后的值,如果已经加密则返回原值
|
||||
*/
|
||||
static migrateFieldToEncrypted(
|
||||
fieldValue: string,
|
||||
userKEK: Buffer,
|
||||
recordId: string,
|
||||
fieldName: string
|
||||
fieldName: string,
|
||||
): { encrypted: string; wasPlaintext: boolean } {
|
||||
if (!fieldValue) {
|
||||
return { encrypted: "", wasPlaintext: false };
|
||||
}
|
||||
|
||||
if (this.isPlaintextField(fieldValue)) {
|
||||
// 明文数据,需要加密
|
||||
try {
|
||||
const encrypted = FieldCrypto.encryptField(fieldValue, userKEK, recordId, fieldName);
|
||||
|
||||
databaseLogger.info("Field migrated from plaintext to encrypted", {
|
||||
operation: "lazy_encryption_migrate_success",
|
||||
const encrypted = FieldCrypto.encryptField(
|
||||
fieldValue,
|
||||
userKEK,
|
||||
recordId,
|
||||
fieldName,
|
||||
plaintextLength: fieldValue.length,
|
||||
});
|
||||
);
|
||||
|
||||
return { encrypted, wasPlaintext: true };
|
||||
} catch (error) {
|
||||
@@ -96,23 +85,19 @@ export class LazyFieldEncryption {
|
||||
throw error;
|
||||
}
|
||||
} else {
|
||||
// 已经加密,无需处理
|
||||
return { encrypted: fieldValue, wasPlaintext: false };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量迁移记录中的敏感字段
|
||||
*/
|
||||
static migrateRecordSensitiveFields(
|
||||
record: any,
|
||||
sensitiveFields: string[],
|
||||
userKEK: Buffer,
|
||||
recordId: string
|
||||
recordId: string,
|
||||
): {
|
||||
updatedRecord: any;
|
||||
migratedFields: string[];
|
||||
needsUpdate: boolean
|
||||
needsUpdate: boolean;
|
||||
} {
|
||||
const updatedRecord = { ...record };
|
||||
const migratedFields: string[] = [];
|
||||
@@ -127,7 +112,7 @@ export class LazyFieldEncryption {
|
||||
fieldValue,
|
||||
userKEK,
|
||||
recordId,
|
||||
fieldName
|
||||
fieldName,
|
||||
);
|
||||
|
||||
updatedRecord[fieldName] = encrypted;
|
||||
@@ -139,55 +124,48 @@ export class LazyFieldEncryption {
|
||||
recordId,
|
||||
fieldName,
|
||||
});
|
||||
// 不抛出错误,继续处理其他字段
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (needsUpdate) {
|
||||
databaseLogger.info("Record requires sensitive field migration", {
|
||||
operation: "lazy_encryption_record_migration_needed",
|
||||
recordId,
|
||||
migratedFields,
|
||||
totalMigratedFields: migratedFields.length,
|
||||
});
|
||||
}
|
||||
|
||||
return { updatedRecord, migratedFields, needsUpdate };
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取敏感字段列表 - 定义哪些字段需要延迟加密
|
||||
*/
|
||||
static getSensitiveFieldsForTable(tableName: string): string[] {
|
||||
const sensitiveFieldsMap: Record<string, string[]> = {
|
||||
'ssh_data': ['password', 'key', 'key_password'],
|
||||
'ssh_credentials': ['password', 'key', 'key_password', 'private_key'],
|
||||
'users': ['totp_secret', 'totp_backup_codes'],
|
||||
ssh_data: ["password", "key", "key_password"],
|
||||
ssh_credentials: ["password", "key", "key_password", "private_key"],
|
||||
users: ["totp_secret", "totp_backup_codes"],
|
||||
};
|
||||
|
||||
return sensitiveFieldsMap[tableName] || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查用户是否有需要迁移的明文数据
|
||||
*/
|
||||
static async checkUserNeedsMigration(
|
||||
userId: string,
|
||||
userKEK: Buffer,
|
||||
db: any
|
||||
db: any,
|
||||
): Promise<{
|
||||
needsMigration: boolean;
|
||||
plaintextFields: Array<{ table: string; recordId: string; fields: string[] }>;
|
||||
plaintextFields: Array<{
|
||||
table: string;
|
||||
recordId: string;
|
||||
fields: string[];
|
||||
}>;
|
||||
}> {
|
||||
const plaintextFields: Array<{ table: string; recordId: string; fields: string[] }> = [];
|
||||
const plaintextFields: Array<{
|
||||
table: string;
|
||||
recordId: string;
|
||||
fields: string[];
|
||||
}> = [];
|
||||
let needsMigration = false;
|
||||
|
||||
try {
|
||||
// 检查 ssh_data 表
|
||||
const sshHosts = db.prepare("SELECT * FROM ssh_data WHERE user_id = ?").all(userId);
|
||||
const sshHosts = db
|
||||
.prepare("SELECT * FROM ssh_data WHERE user_id = ?")
|
||||
.all(userId);
|
||||
for (const host of sshHosts) {
|
||||
const sensitiveFields = this.getSensitiveFieldsForTable('ssh_data');
|
||||
const sensitiveFields = this.getSensitiveFieldsForTable("ssh_data");
|
||||
const hostPlaintextFields: string[] = [];
|
||||
|
||||
for (const field of sensitiveFields) {
|
||||
@@ -199,17 +177,19 @@ export class LazyFieldEncryption {
|
||||
|
||||
if (hostPlaintextFields.length > 0) {
|
||||
plaintextFields.push({
|
||||
table: 'ssh_data',
|
||||
table: "ssh_data",
|
||||
recordId: host.id.toString(),
|
||||
fields: hostPlaintextFields,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 检查 ssh_credentials 表
|
||||
const sshCredentials = db.prepare("SELECT * FROM ssh_credentials WHERE user_id = ?").all(userId);
|
||||
const sshCredentials = db
|
||||
.prepare("SELECT * FROM ssh_credentials WHERE user_id = ?")
|
||||
.all(userId);
|
||||
for (const credential of sshCredentials) {
|
||||
const sensitiveFields = this.getSensitiveFieldsForTable('ssh_credentials');
|
||||
const sensitiveFields =
|
||||
this.getSensitiveFieldsForTable("ssh_credentials");
|
||||
const credentialPlaintextFields: string[] = [];
|
||||
|
||||
for (const field of sensitiveFields) {
|
||||
@@ -221,17 +201,16 @@ export class LazyFieldEncryption {
|
||||
|
||||
if (credentialPlaintextFields.length > 0) {
|
||||
plaintextFields.push({
|
||||
table: 'ssh_credentials',
|
||||
table: "ssh_credentials",
|
||||
recordId: credential.id.toString(),
|
||||
fields: credentialPlaintextFields,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 检查 users 表中的敏感字段
|
||||
const user = db.prepare("SELECT * FROM users WHERE id = ?").get(userId);
|
||||
if (user) {
|
||||
const sensitiveFields = this.getSensitiveFieldsForTable('users');
|
||||
const sensitiveFields = this.getSensitiveFieldsForTable("users");
|
||||
const userPlaintextFields: string[] = [];
|
||||
|
||||
for (const field of sensitiveFields) {
|
||||
@@ -243,23 +222,14 @@ export class LazyFieldEncryption {
|
||||
|
||||
if (userPlaintextFields.length > 0) {
|
||||
plaintextFields.push({
|
||||
table: 'users',
|
||||
table: "users",
|
||||
recordId: userId,
|
||||
fields: userPlaintextFields,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
databaseLogger.info("User migration check completed", {
|
||||
operation: "lazy_encryption_user_check",
|
||||
userId,
|
||||
needsMigration,
|
||||
plaintextFieldsCount: plaintextFields.length,
|
||||
totalPlaintextFields: plaintextFields.reduce((sum, item) => sum + item.fields.length, 0),
|
||||
});
|
||||
|
||||
return { needsMigration, plaintextFields };
|
||||
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to check user migration needs", error, {
|
||||
operation: "lazy_encryption_user_check_failed",
|
||||
@@ -270,4 +240,4 @@ export class LazyFieldEncryption {
|
||||
return { needsMigration: false, plaintextFields: [] };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,23 +14,35 @@ export interface LogContext {
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
// Sensitive fields that should be masked in logs
|
||||
const SENSITIVE_FIELDS = [
|
||||
'password', 'passphrase', 'key', 'privateKey', 'publicKey', 'token', 'secret',
|
||||
'clientSecret', 'keyPassword', 'autostartPassword', 'autostartKey', 'autostartKeyPassword',
|
||||
'credentialId', 'authToken', 'jwt', 'session', 'cookie'
|
||||
"password",
|
||||
"passphrase",
|
||||
"key",
|
||||
"privateKey",
|
||||
"publicKey",
|
||||
"token",
|
||||
"secret",
|
||||
"clientSecret",
|
||||
"keyPassword",
|
||||
"autostartPassword",
|
||||
"autostartKey",
|
||||
"autostartKeyPassword",
|
||||
"credentialId",
|
||||
"authToken",
|
||||
"jwt",
|
||||
"session",
|
||||
"cookie",
|
||||
];
|
||||
|
||||
// Fields that should be truncated if too long
|
||||
const TRUNCATE_FIELDS = ['data', 'content', 'body', 'response', 'request'];
|
||||
const TRUNCATE_FIELDS = ["data", "content", "body", "response", "request"];
|
||||
|
||||
class Logger {
|
||||
private serviceName: string;
|
||||
private serviceIcon: string;
|
||||
private serviceColor: string;
|
||||
private logCounts = new Map<string, { count: number; lastLog: number }>();
|
||||
private readonly RATE_LIMIT_WINDOW = 60000; // 1 minute
|
||||
private readonly RATE_LIMIT_MAX = 10; // Max logs per minute for same message
|
||||
private readonly RATE_LIMIT_WINDOW = 60000;
|
||||
private readonly RATE_LIMIT_MAX = 10;
|
||||
|
||||
constructor(serviceName: string, serviceIcon: string, serviceColor: string) {
|
||||
this.serviceName = serviceName;
|
||||
@@ -44,27 +56,32 @@ class Logger {
|
||||
|
||||
private sanitizeContext(context: LogContext): LogContext {
|
||||
const sanitized = { ...context };
|
||||
|
||||
// Mask sensitive fields
|
||||
|
||||
for (const field of SENSITIVE_FIELDS) {
|
||||
if (sanitized[field] !== undefined) {
|
||||
if (typeof sanitized[field] === 'string' && sanitized[field].length > 0) {
|
||||
sanitized[field] = '[MASKED]';
|
||||
} else if (typeof sanitized[field] === 'boolean') {
|
||||
sanitized[field] = sanitized[field] ? '[PRESENT]' : '[ABSENT]';
|
||||
if (
|
||||
typeof sanitized[field] === "string" &&
|
||||
sanitized[field].length > 0
|
||||
) {
|
||||
sanitized[field] = "[MASKED]";
|
||||
} else if (typeof sanitized[field] === "boolean") {
|
||||
sanitized[field] = sanitized[field] ? "[PRESENT]" : "[ABSENT]";
|
||||
} else {
|
||||
sanitized[field] = '[MASKED]';
|
||||
sanitized[field] = "[MASKED]";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Truncate long fields
|
||||
|
||||
for (const field of TRUNCATE_FIELDS) {
|
||||
if (sanitized[field] && typeof sanitized[field] === 'string' && sanitized[field].length > 100) {
|
||||
sanitized[field] = sanitized[field].substring(0, 100) + '...';
|
||||
if (
|
||||
sanitized[field] &&
|
||||
typeof sanitized[field] === "string" &&
|
||||
sanitized[field].length > 100
|
||||
) {
|
||||
sanitized[field] = sanitized[field].substring(0, 100) + "...";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
@@ -82,13 +99,20 @@ class Logger {
|
||||
if (context) {
|
||||
const sanitizedContext = this.sanitizeContext(context);
|
||||
const contextParts = [];
|
||||
if (sanitizedContext.operation) contextParts.push(`op:${sanitizedContext.operation}`);
|
||||
if (sanitizedContext.userId) contextParts.push(`user:${sanitizedContext.userId}`);
|
||||
if (sanitizedContext.hostId) contextParts.push(`host:${sanitizedContext.hostId}`);
|
||||
if (sanitizedContext.tunnelName) contextParts.push(`tunnel:${sanitizedContext.tunnelName}`);
|
||||
if (sanitizedContext.sessionId) contextParts.push(`session:${sanitizedContext.sessionId}`);
|
||||
if (sanitizedContext.requestId) contextParts.push(`req:${sanitizedContext.requestId}`);
|
||||
if (sanitizedContext.duration) contextParts.push(`duration:${sanitizedContext.duration}ms`);
|
||||
if (sanitizedContext.operation)
|
||||
contextParts.push(`op:${sanitizedContext.operation}`);
|
||||
if (sanitizedContext.userId)
|
||||
contextParts.push(`user:${sanitizedContext.userId}`);
|
||||
if (sanitizedContext.hostId)
|
||||
contextParts.push(`host:${sanitizedContext.hostId}`);
|
||||
if (sanitizedContext.tunnelName)
|
||||
contextParts.push(`tunnel:${sanitizedContext.tunnelName}`);
|
||||
if (sanitizedContext.sessionId)
|
||||
contextParts.push(`session:${sanitizedContext.sessionId}`);
|
||||
if (sanitizedContext.requestId)
|
||||
contextParts.push(`req:${sanitizedContext.requestId}`);
|
||||
if (sanitizedContext.duration)
|
||||
contextParts.push(`duration:${sanitizedContext.duration}ms`);
|
||||
|
||||
if (contextParts.length > 0) {
|
||||
contextStr = chalk.gray(` [${contextParts.join(",")}]`);
|
||||
@@ -119,27 +143,25 @@ class Logger {
|
||||
if (level === "debug" && process.env.NODE_ENV === "production") {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Rate limiting for frequent messages
|
||||
|
||||
const now = Date.now();
|
||||
const logKey = `${level}:${message}`;
|
||||
const logInfo = this.logCounts.get(logKey);
|
||||
|
||||
|
||||
if (logInfo) {
|
||||
if (now - logInfo.lastLog < this.RATE_LIMIT_WINDOW) {
|
||||
logInfo.count++;
|
||||
if (logInfo.count > this.RATE_LIMIT_MAX) {
|
||||
return false; // Rate limited
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
// Reset counter for new window
|
||||
logInfo.count = 1;
|
||||
logInfo.lastLog = now;
|
||||
}
|
||||
} else {
|
||||
this.logCounts.set(logKey, { count: 1, lastLog: now });
|
||||
}
|
||||
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,131 +1,94 @@
|
||||
import { getDb, DatabaseSaveTrigger } from "../database/db/index.js";
|
||||
import { DataCrypto } from "./data-crypto.js";
|
||||
import { databaseLogger } from "./logger.js";
|
||||
import type { SQLiteTable } from "drizzle-orm/sqlite-core";
|
||||
|
||||
type TableName = "users" | "ssh_data" | "ssh_credentials";
|
||||
|
||||
/**
|
||||
* SimpleDBOps - Simplified encrypted database operations
|
||||
*
|
||||
* Linus-style simplification:
|
||||
* - Remove all complex abstraction layers
|
||||
* - Direct CRUD operations
|
||||
* - Automatic encryption/decryption
|
||||
* - No special case handling
|
||||
*/
|
||||
class SimpleDBOps {
|
||||
/**
|
||||
* Insert encrypted record
|
||||
*/
|
||||
static async insert<T extends Record<string, any>>(
|
||||
table: SQLiteTable<any>,
|
||||
tableName: TableName,
|
||||
data: T,
|
||||
userId: string,
|
||||
): Promise<T> {
|
||||
// Get user data key once and reuse throughout operation
|
||||
const userDataKey = DataCrypto.validateUserAccess(userId);
|
||||
|
||||
// Generate consistent temporary ID for encryption context if record has no ID
|
||||
const tempId = data.id || `temp-${userId}-${Date.now()}`;
|
||||
const dataWithTempId = { ...data, id: tempId };
|
||||
|
||||
// Encrypt data using the locked key - recordId will be stored in encrypted fields
|
||||
const encryptedData = DataCrypto.encryptRecord(tableName, dataWithTempId, userId, userDataKey);
|
||||
const encryptedData = DataCrypto.encryptRecord(
|
||||
tableName,
|
||||
dataWithTempId,
|
||||
userId,
|
||||
userDataKey,
|
||||
);
|
||||
|
||||
// Remove temp ID if it was generated, let database assign real ID
|
||||
if (!data.id) {
|
||||
delete encryptedData.id;
|
||||
}
|
||||
|
||||
// Insert into database
|
||||
const result = await getDb().insert(table).values(encryptedData).returning();
|
||||
const result = await getDb()
|
||||
.insert(table)
|
||||
.values(encryptedData)
|
||||
.returning();
|
||||
|
||||
// Trigger database save after insert
|
||||
DatabaseSaveTrigger.triggerSave(`insert_${tableName}`);
|
||||
|
||||
// Decrypt return result using the same key - FieldCrypto will use stored recordId
|
||||
const decryptedResult = DataCrypto.decryptRecord(
|
||||
tableName,
|
||||
result[0],
|
||||
userId,
|
||||
userDataKey
|
||||
userDataKey,
|
||||
);
|
||||
|
||||
return decryptedResult as T;
|
||||
}
|
||||
|
||||
/**
|
||||
* Query multiple records
|
||||
*/
|
||||
static async select<T extends Record<string, any>>(
|
||||
query: any,
|
||||
tableName: TableName,
|
||||
userId: string,
|
||||
): Promise<T[]> {
|
||||
// Check if user data is unlocked - return empty array if locked
|
||||
const userDataKey = DataCrypto.getUserDataKey(userId);
|
||||
if (!userDataKey) {
|
||||
databaseLogger.debug("User data locked - returning empty results", {
|
||||
operation: "select_data_locked",
|
||||
userId,
|
||||
tableName
|
||||
});
|
||||
return [];
|
||||
}
|
||||
|
||||
// Execute query
|
||||
const results = await query;
|
||||
|
||||
// Decrypt results using locked key
|
||||
const decryptedResults = DataCrypto.decryptRecords(
|
||||
tableName,
|
||||
results,
|
||||
userId,
|
||||
userDataKey
|
||||
userDataKey,
|
||||
);
|
||||
|
||||
return decryptedResults;
|
||||
}
|
||||
|
||||
/**
|
||||
* Query single record
|
||||
*/
|
||||
static async selectOne<T extends Record<string, any>>(
|
||||
query: any,
|
||||
tableName: TableName,
|
||||
userId: string,
|
||||
): Promise<T | undefined> {
|
||||
// Check if user data is unlocked - return undefined if locked
|
||||
const userDataKey = DataCrypto.getUserDataKey(userId);
|
||||
if (!userDataKey) {
|
||||
databaseLogger.debug("User data locked - returning undefined", {
|
||||
operation: "selectOne_data_locked",
|
||||
userId,
|
||||
tableName
|
||||
});
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Execute query
|
||||
const result = await query;
|
||||
if (!result) return undefined;
|
||||
|
||||
// Decrypt results using locked key
|
||||
const decryptedResult = DataCrypto.decryptRecord(
|
||||
tableName,
|
||||
result,
|
||||
userId,
|
||||
userDataKey
|
||||
userDataKey,
|
||||
);
|
||||
|
||||
return decryptedResult;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update record
|
||||
*/
|
||||
static async update<T extends Record<string, any>>(
|
||||
table: SQLiteTable<any>,
|
||||
tableName: TableName,
|
||||
@@ -133,36 +96,33 @@ class SimpleDBOps {
|
||||
data: Partial<T>,
|
||||
userId: string,
|
||||
): Promise<T[]> {
|
||||
// Get user data key once and reuse throughout operation
|
||||
const userDataKey = DataCrypto.validateUserAccess(userId);
|
||||
|
||||
// Encrypt update data using the locked key
|
||||
const encryptedData = DataCrypto.encryptRecord(tableName, data, userId, userDataKey);
|
||||
const encryptedData = DataCrypto.encryptRecord(
|
||||
tableName,
|
||||
data,
|
||||
userId,
|
||||
userDataKey,
|
||||
);
|
||||
|
||||
// Execute update
|
||||
const result = await getDb()
|
||||
.update(table)
|
||||
.set(encryptedData)
|
||||
.where(where)
|
||||
.returning();
|
||||
|
||||
// Trigger database save after update
|
||||
DatabaseSaveTrigger.triggerSave(`update_${tableName}`);
|
||||
|
||||
// Decrypt return data using the same key
|
||||
const decryptedResults = DataCrypto.decryptRecords(
|
||||
tableName,
|
||||
result,
|
||||
userId,
|
||||
userDataKey
|
||||
userDataKey,
|
||||
);
|
||||
|
||||
return decryptedResults as T[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete record
|
||||
*/
|
||||
static async delete(
|
||||
table: SQLiteTable<any>,
|
||||
tableName: TableName,
|
||||
@@ -171,36 +131,27 @@ class SimpleDBOps {
|
||||
): Promise<any[]> {
|
||||
const result = await getDb().delete(table).where(where).returning();
|
||||
|
||||
// Trigger database save after delete
|
||||
DatabaseSaveTrigger.triggerSave(`delete_${tableName}`);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Health check
|
||||
*/
|
||||
static async healthCheck(userId: string): Promise<boolean> {
|
||||
return DataCrypto.canUserAccessData(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user data is unlocked
|
||||
*/
|
||||
static isUserDataUnlocked(userId: string): boolean {
|
||||
return DataCrypto.getUserDataKey(userId) !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Special method: return encrypted data (for auto-start scenarios)
|
||||
* No decryption, return data in encrypted state directly
|
||||
*/
|
||||
static async selectEncrypted(query: any, tableName: TableName): Promise<any[]> {
|
||||
// Execute query directly, no decryption
|
||||
static async selectEncrypted(
|
||||
query: any,
|
||||
tableName: TableName,
|
||||
): Promise<any[]> {
|
||||
const results = await query;
|
||||
|
||||
return results;
|
||||
}
|
||||
}
|
||||
|
||||
export { SimpleDBOps, type TableName };
|
||||
export { SimpleDBOps, type TableName };
|
||||
|
||||
@@ -1,14 +1,10 @@
|
||||
// Import SSH2 using ES modules
|
||||
import ssh2Pkg from "ssh2";
|
||||
const ssh2Utils = ssh2Pkg.utils;
|
||||
|
||||
// Simple fallback SSH key type detection
|
||||
function detectKeyTypeFromContent(keyContent: string): string {
|
||||
const content = keyContent.trim();
|
||||
|
||||
// Check for OpenSSH format headers
|
||||
if (content.includes("-----BEGIN OPENSSH PRIVATE KEY-----")) {
|
||||
// Look for key type indicators in the content
|
||||
if (
|
||||
content.includes("ssh-ed25519") ||
|
||||
content.includes("AAAAC3NzaC1lZDI1NTE5")
|
||||
@@ -28,14 +24,12 @@ function detectKeyTypeFromContent(keyContent: string): string {
|
||||
return "ecdsa-sha2-nistp521";
|
||||
}
|
||||
|
||||
// For OpenSSH format, try to detect by analyzing the base64 content structure
|
||||
try {
|
||||
const base64Content = content
|
||||
.replace("-----BEGIN OPENSSH PRIVATE KEY-----", "")
|
||||
.replace("-----END OPENSSH PRIVATE KEY-----", "")
|
||||
.replace(/\s/g, "");
|
||||
|
||||
// OpenSSH format starts with "openssh-key-v1" followed by key type
|
||||
const decoded = Buffer.from(base64Content, "base64").toString("binary");
|
||||
|
||||
if (decoded.includes("ssh-rsa")) {
|
||||
@@ -54,15 +48,12 @@ function detectKeyTypeFromContent(keyContent: string): string {
|
||||
return "ecdsa-sha2-nistp521";
|
||||
}
|
||||
|
||||
// Default to RSA for OpenSSH format if we can't detect specifically
|
||||
return "ssh-rsa";
|
||||
} catch (error) {
|
||||
// If decoding fails, default to RSA as it's most common for OpenSSH format
|
||||
return "ssh-rsa";
|
||||
}
|
||||
}
|
||||
|
||||
// Check for traditional PEM headers
|
||||
if (content.includes("-----BEGIN RSA PRIVATE KEY-----")) {
|
||||
return "ssh-rsa";
|
||||
}
|
||||
@@ -70,12 +61,10 @@ function detectKeyTypeFromContent(keyContent: string): string {
|
||||
return "ssh-dss";
|
||||
}
|
||||
if (content.includes("-----BEGIN EC PRIVATE KEY-----")) {
|
||||
return "ecdsa-sha2-nistp256"; // Default ECDSA type
|
||||
return "ecdsa-sha2-nistp256";
|
||||
}
|
||||
|
||||
// Check for PKCS#8 format (modern format)
|
||||
if (content.includes("-----BEGIN PRIVATE KEY-----")) {
|
||||
// Try to decode and analyze the DER structure for better detection
|
||||
try {
|
||||
const base64Content = content
|
||||
.replace("-----BEGIN PRIVATE KEY-----", "")
|
||||
@@ -85,35 +74,23 @@ function detectKeyTypeFromContent(keyContent: string): string {
|
||||
const decoded = Buffer.from(base64Content, "base64");
|
||||
const decodedString = decoded.toString("binary");
|
||||
|
||||
// Check for algorithm identifiers in the DER structure
|
||||
if (decodedString.includes("1.2.840.113549.1.1.1")) {
|
||||
// RSA OID
|
||||
return "ssh-rsa";
|
||||
} else if (decodedString.includes("1.2.840.10045.2.1")) {
|
||||
// EC Private Key OID - this indicates ECDSA
|
||||
if (decodedString.includes("1.2.840.10045.3.1.7")) {
|
||||
// prime256v1 curve OID
|
||||
return "ecdsa-sha2-nistp256";
|
||||
}
|
||||
return "ecdsa-sha2-nistp256"; // Default to P-256
|
||||
return "ecdsa-sha2-nistp256";
|
||||
} else if (decodedString.includes("1.3.101.112")) {
|
||||
// Ed25519 OID
|
||||
return "ssh-ed25519";
|
||||
}
|
||||
} catch (error) {
|
||||
// If decoding fails, fall back to length-based detection
|
||||
}
|
||||
} catch (error) {}
|
||||
|
||||
// Fallback: Try to detect key type from the content structure
|
||||
// This is a fallback for PKCS#8 format keys
|
||||
if (content.length < 800) {
|
||||
// Ed25519 keys are typically shorter
|
||||
return "ssh-ed25519";
|
||||
} else if (content.length > 1600) {
|
||||
// RSA keys are typically longer
|
||||
return "ssh-rsa";
|
||||
} else {
|
||||
// ECDSA keys are typically medium length
|
||||
return "ecdsa-sha2-nistp256";
|
||||
}
|
||||
}
|
||||
@@ -121,11 +98,9 @@ function detectKeyTypeFromContent(keyContent: string): string {
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
// Detect public key type from public key content
|
||||
function detectPublicKeyTypeFromContent(publicKeyContent: string): string {
|
||||
const content = publicKeyContent.trim();
|
||||
|
||||
// SSH public keys start with the key type
|
||||
if (content.startsWith("ssh-rsa ")) {
|
||||
return "ssh-rsa";
|
||||
}
|
||||
@@ -145,9 +120,7 @@ function detectPublicKeyTypeFromContent(publicKeyContent: string): string {
|
||||
return "ssh-dss";
|
||||
}
|
||||
|
||||
// Check for PEM format public keys
|
||||
if (content.includes("-----BEGIN PUBLIC KEY-----")) {
|
||||
// Try to decode the base64 content to detect key type
|
||||
try {
|
||||
const base64Content = content
|
||||
.replace("-----BEGIN PUBLIC KEY-----", "")
|
||||
@@ -157,26 +130,18 @@ function detectPublicKeyTypeFromContent(publicKeyContent: string): string {
|
||||
const decoded = Buffer.from(base64Content, "base64");
|
||||
const decodedString = decoded.toString("binary");
|
||||
|
||||
// Check for algorithm identifiers in the DER structure
|
||||
if (decodedString.includes("1.2.840.113549.1.1.1")) {
|
||||
// RSA OID
|
||||
return "ssh-rsa";
|
||||
} else if (decodedString.includes("1.2.840.10045.2.1")) {
|
||||
// EC Public Key OID - this indicates ECDSA
|
||||
if (decodedString.includes("1.2.840.10045.3.1.7")) {
|
||||
// prime256v1 curve OID
|
||||
return "ecdsa-sha2-nistp256";
|
||||
}
|
||||
return "ecdsa-sha2-nistp256"; // Default to P-256
|
||||
return "ecdsa-sha2-nistp256";
|
||||
} else if (decodedString.includes("1.3.101.112")) {
|
||||
// Ed25519 OID
|
||||
return "ssh-ed25519";
|
||||
}
|
||||
} catch (error) {
|
||||
// If decoding fails, fall back to length-based detection
|
||||
}
|
||||
} catch (error) {}
|
||||
|
||||
// Fallback: Try to guess based on key length
|
||||
if (content.length < 400) {
|
||||
return "ssh-ed25519";
|
||||
} else if (content.length > 600) {
|
||||
@@ -190,7 +155,6 @@ function detectPublicKeyTypeFromContent(publicKeyContent: string): string {
|
||||
return "ssh-rsa";
|
||||
}
|
||||
|
||||
// Check for base64 encoded key data patterns
|
||||
if (content.includes("AAAAB3NzaC1yc2E")) {
|
||||
return "ssh-rsa";
|
||||
}
|
||||
@@ -236,9 +200,6 @@ export interface KeyPairValidationResult {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse SSH private key and extract public key and type information
|
||||
*/
|
||||
export function parseSSHKey(
|
||||
privateKeyData: string,
|
||||
passphrase?: string,
|
||||
@@ -248,28 +209,21 @@ export function parseSSHKey(
|
||||
let publicKey = "";
|
||||
let useSSH2 = false;
|
||||
|
||||
// Try SSH2 first if available
|
||||
if (ssh2Utils && typeof ssh2Utils.parseKey === "function") {
|
||||
try {
|
||||
const parsedKey = ssh2Utils.parseKey(privateKeyData, passphrase);
|
||||
|
||||
if (!(parsedKey instanceof Error)) {
|
||||
// Extract key type
|
||||
if (parsedKey.type) {
|
||||
keyType = parsedKey.type;
|
||||
}
|
||||
|
||||
// Generate public key in SSH format
|
||||
try {
|
||||
const publicKeyBuffer = parsedKey.getPublicSSH();
|
||||
|
||||
// ssh2's getPublicSSH() returns binary SSH protocol data, not text
|
||||
// We need to convert this to proper SSH public key format
|
||||
if (Buffer.isBuffer(publicKeyBuffer)) {
|
||||
// Convert binary SSH data to base64 and create proper SSH key format
|
||||
const base64Data = publicKeyBuffer.toString("base64");
|
||||
|
||||
// Create proper SSH public key format: "keytype base64data"
|
||||
if (keyType === "ssh-rsa") {
|
||||
publicKey = `ssh-rsa ${base64Data}`;
|
||||
} else if (keyType === "ssh-ed25519") {
|
||||
@@ -288,16 +242,12 @@ export function parseSSHKey(
|
||||
|
||||
useSSH2 = true;
|
||||
}
|
||||
} catch (error) {
|
||||
// SSH2 parsing failed, will fall back to content detection
|
||||
}
|
||||
} catch (error) {}
|
||||
}
|
||||
|
||||
// Fallback to content-based detection
|
||||
if (!useSSH2) {
|
||||
keyType = detectKeyTypeFromContent(privateKeyData);
|
||||
|
||||
// For fallback, we can't generate public key but the detection is still useful
|
||||
publicKey = "";
|
||||
}
|
||||
|
||||
@@ -308,7 +258,6 @@ export function parseSSHKey(
|
||||
success: keyType !== "unknown",
|
||||
};
|
||||
} catch (error) {
|
||||
// Final fallback - try content detection
|
||||
try {
|
||||
const fallbackKeyType = detectKeyTypeFromContent(privateKeyData);
|
||||
if (fallbackKeyType !== "unknown") {
|
||||
@@ -319,9 +268,7 @@ export function parseSSHKey(
|
||||
success: true,
|
||||
};
|
||||
}
|
||||
} catch (fallbackError) {
|
||||
// Even fallback detection failed
|
||||
}
|
||||
} catch (fallbackError) {}
|
||||
|
||||
return {
|
||||
privateKey: privateKeyData,
|
||||
@@ -334,9 +281,6 @@ export function parseSSHKey(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse SSH public key and extract type information
|
||||
*/
|
||||
export function parsePublicKey(publicKeyData: string): PublicKeyInfo {
|
||||
try {
|
||||
const keyType = detectPublicKeyTypeFromContent(publicKeyData);
|
||||
@@ -359,9 +303,6 @@ export function parsePublicKey(publicKeyData: string): PublicKeyInfo {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect SSH key type from private key content
|
||||
*/
|
||||
export function detectKeyType(privateKeyData: string): string {
|
||||
try {
|
||||
const parsedKey = ssh2Utils.parseKey(privateKeyData);
|
||||
@@ -374,9 +315,6 @@ export function detectKeyType(privateKeyData: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get friendly key type name
|
||||
*/
|
||||
export function getFriendlyKeyTypeName(keyType: string): string {
|
||||
const keyTypeMap: Record<string, string> = {
|
||||
"ssh-rsa": "RSA",
|
||||
@@ -393,16 +331,12 @@ export function getFriendlyKeyTypeName(keyType: string): string {
|
||||
return keyTypeMap[keyType] || keyType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate if a private key and public key form a valid key pair
|
||||
*/
|
||||
export function validateKeyPair(
|
||||
privateKeyData: string,
|
||||
publicKeyData: string,
|
||||
passphrase?: string,
|
||||
): KeyPairValidationResult {
|
||||
try {
|
||||
// First parse the private key and try to generate public key
|
||||
const privateKeyInfo = parseSSHKey(privateKeyData, passphrase);
|
||||
const publicKeyInfo = parsePublicKey(publicKeyData);
|
||||
|
||||
@@ -424,7 +358,6 @@ export function validateKeyPair(
|
||||
};
|
||||
}
|
||||
|
||||
// Check if key types match
|
||||
if (privateKeyInfo.keyType !== publicKeyInfo.keyType) {
|
||||
return {
|
||||
isValid: false,
|
||||
@@ -434,17 +367,14 @@ export function validateKeyPair(
|
||||
};
|
||||
}
|
||||
|
||||
// If we have a generated public key from the private key, compare them
|
||||
if (privateKeyInfo.publicKey && privateKeyInfo.publicKey.trim()) {
|
||||
const generatedPublicKey = privateKeyInfo.publicKey.trim();
|
||||
const providedPublicKey = publicKeyData.trim();
|
||||
|
||||
// Compare the key data part (excluding comments)
|
||||
const generatedKeyParts = generatedPublicKey.split(" ");
|
||||
const providedKeyParts = providedPublicKey.split(" ");
|
||||
|
||||
if (generatedKeyParts.length >= 2 && providedKeyParts.length >= 2) {
|
||||
// Compare key type and key data (first two parts)
|
||||
const generatedKeyData =
|
||||
generatedKeyParts[0] + " " + generatedKeyParts[1];
|
||||
const providedKeyData = providedKeyParts[0] + " " + providedKeyParts[1];
|
||||
@@ -468,9 +398,8 @@ export function validateKeyPair(
|
||||
}
|
||||
}
|
||||
|
||||
// If we can't generate public key or compare, just check if types match
|
||||
return {
|
||||
isValid: true, // Assume valid if types match and no errors
|
||||
isValid: true,
|
||||
privateKeyType: privateKeyInfo.keyType,
|
||||
publicKeyType: publicKeyInfo.keyType,
|
||||
error: "Unable to verify key pair match, but key types are compatible",
|
||||
|
||||
@@ -3,22 +3,12 @@ import { promises as fs } from "fs";
|
||||
import path from "path";
|
||||
import { databaseLogger } from "./logger.js";
|
||||
|
||||
/**
|
||||
* SystemCrypto - Open source friendly system key management
|
||||
*
|
||||
* Linus principles:
|
||||
* - Remove complex "system master key" layer - doesn't solve real threats
|
||||
* - Remove hardcoded default keys - security disaster for open source software
|
||||
* - Auto-generate on first startup - each instance independently secure
|
||||
* - Simple and direct, focus on real security boundaries
|
||||
*/
|
||||
class SystemCrypto {
|
||||
private static instance: SystemCrypto;
|
||||
private jwtSecret: string | null = null;
|
||||
private databaseKey: Buffer | null = null;
|
||||
private internalAuthToken: string | null = null;
|
||||
|
||||
|
||||
private constructor() {}
|
||||
|
||||
static getInstance(): SystemCrypto {
|
||||
@@ -28,25 +18,15 @@ class SystemCrypto {
|
||||
return this.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize JWT secret - environment variable only
|
||||
*/
|
||||
async initializeJWTSecret(): Promise<void> {
|
||||
try {
|
||||
databaseLogger.info("Initializing JWT secret", {
|
||||
operation: "jwt_init",
|
||||
});
|
||||
|
||||
// Check environment variable
|
||||
const envSecret = process.env.JWT_SECRET;
|
||||
if (envSecret && envSecret.length >= 64) {
|
||||
this.jwtSecret = envSecret;
|
||||
return;
|
||||
}
|
||||
|
||||
// No environment variable - generate and guide user
|
||||
await this.generateAndGuideUser();
|
||||
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to initialize JWT secret", error, {
|
||||
operation: "jwt_init_failed",
|
||||
@@ -55,9 +35,6 @@ class SystemCrypto {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get JWT secret
|
||||
*/
|
||||
async getJWTSecret(): Promise<string> {
|
||||
if (!this.jwtSecret) {
|
||||
await this.initializeJWTSecret();
|
||||
@@ -65,36 +42,15 @@ class SystemCrypto {
|
||||
return this.jwtSecret!;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize database encryption key - environment variable only
|
||||
*/
|
||||
async initializeDatabaseKey(): Promise<void> {
|
||||
try {
|
||||
databaseLogger.info("Initializing database encryption key", {
|
||||
operation: "db_key_init",
|
||||
});
|
||||
|
||||
// Check environment variable
|
||||
const envKey = process.env.DATABASE_KEY;
|
||||
databaseLogger.info("Checking DATABASE_KEY from environment", {
|
||||
operation: "db_key_check",
|
||||
hasKey: !!envKey,
|
||||
keyLength: envKey?.length || 0,
|
||||
meetsLengthRequirement: envKey && envKey.length >= 64
|
||||
});
|
||||
|
||||
if (envKey && envKey.length >= 64) {
|
||||
this.databaseKey = Buffer.from(envKey, 'hex');
|
||||
databaseLogger.info("Using existing DATABASE_KEY from environment", {
|
||||
operation: "db_key_use_existing",
|
||||
keyLength: envKey.length
|
||||
});
|
||||
this.databaseKey = Buffer.from(envKey, "hex");
|
||||
return;
|
||||
}
|
||||
|
||||
// No environment variable - generate and guide user
|
||||
await this.generateAndGuideDatabaseKey();
|
||||
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to initialize database key", error, {
|
||||
operation: "db_key_init_failed",
|
||||
@@ -103,9 +59,6 @@ class SystemCrypto {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get database encryption key
|
||||
*/
|
||||
async getDatabaseKey(): Promise<Buffer> {
|
||||
if (!this.databaseKey) {
|
||||
await this.initializeDatabaseKey();
|
||||
@@ -113,25 +66,15 @@ class SystemCrypto {
|
||||
return this.databaseKey!;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize internal auth token - environment variable only
|
||||
*/
|
||||
async initializeInternalAuthToken(): Promise<void> {
|
||||
try {
|
||||
databaseLogger.info("Initializing internal auth token", {
|
||||
operation: "internal_auth_init",
|
||||
});
|
||||
|
||||
// Check environment variable
|
||||
const envToken = process.env.INTERNAL_AUTH_TOKEN;
|
||||
if (envToken && envToken.length >= 32) {
|
||||
this.internalAuthToken = envToken;
|
||||
return;
|
||||
}
|
||||
|
||||
// No environment variable - generate and guide user
|
||||
await this.generateAndGuideInternalAuthToken();
|
||||
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to initialize internal auth token", error, {
|
||||
operation: "internal_auth_init_failed",
|
||||
@@ -140,9 +83,6 @@ class SystemCrypto {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get internal auth token
|
||||
*/
|
||||
async getInternalAuthToken(): Promise<string> {
|
||||
if (!this.internalAuthToken) {
|
||||
await this.initializeInternalAuthToken();
|
||||
@@ -150,79 +90,58 @@ class SystemCrypto {
|
||||
return this.internalAuthToken!;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate and auto-save to .env file
|
||||
*/
|
||||
private async generateAndGuideUser(): Promise<void> {
|
||||
const newSecret = crypto.randomBytes(32).toString('hex');
|
||||
const instanceId = crypto.randomBytes(8).toString('hex');
|
||||
const newSecret = crypto.randomBytes(32).toString("hex");
|
||||
const instanceId = crypto.randomBytes(8).toString("hex");
|
||||
|
||||
// Set in memory for current session
|
||||
this.jwtSecret = newSecret;
|
||||
|
||||
// Auto-save to .env file
|
||||
await this.updateEnvFile("JWT_SECRET", newSecret);
|
||||
|
||||
databaseLogger.success("JWT secret auto-generated and saved to .env", {
|
||||
operation: "jwt_auto_generated",
|
||||
instanceId,
|
||||
envVarName: "JWT_SECRET",
|
||||
note: "Ready for use - no restart required"
|
||||
note: "Ready for use - no restart required",
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// ===== Database key generation and storage methods =====
|
||||
|
||||
/**
|
||||
* Generate and auto-save database key to .env file
|
||||
*/
|
||||
private async generateAndGuideDatabaseKey(): Promise<void> {
|
||||
const newKey = crypto.randomBytes(32); // 256-bit key for AES-256
|
||||
const newKeyHex = newKey.toString('hex');
|
||||
const instanceId = crypto.randomBytes(8).toString('hex');
|
||||
const newKey = crypto.randomBytes(32);
|
||||
const newKeyHex = newKey.toString("hex");
|
||||
const instanceId = crypto.randomBytes(8).toString("hex");
|
||||
|
||||
// Set in memory for current session
|
||||
this.databaseKey = newKey;
|
||||
|
||||
// Auto-save to .env file
|
||||
await this.updateEnvFile("DATABASE_KEY", newKeyHex);
|
||||
|
||||
databaseLogger.success("🔒 Database key auto-generated and saved to .env", {
|
||||
databaseLogger.success("Database key auto-generated and saved to .env", {
|
||||
operation: "db_key_auto_generated",
|
||||
instanceId,
|
||||
envVarName: "DATABASE_KEY",
|
||||
note: "Ready for use - no restart required"
|
||||
note: "Ready for use - no restart required",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate and auto-save internal auth token to .env file
|
||||
*/
|
||||
private async generateAndGuideInternalAuthToken(): Promise<void> {
|
||||
const newToken = crypto.randomBytes(32).toString('hex'); // 256-bit token for security
|
||||
const instanceId = crypto.randomBytes(8).toString('hex');
|
||||
const newToken = crypto.randomBytes(32).toString("hex");
|
||||
const instanceId = crypto.randomBytes(8).toString("hex");
|
||||
|
||||
// Set in memory for current session
|
||||
this.internalAuthToken = newToken;
|
||||
|
||||
// Auto-save to .env file
|
||||
await this.updateEnvFile("INTERNAL_AUTH_TOKEN", newToken);
|
||||
|
||||
databaseLogger.success("Internal auth token auto-generated and saved to .env", {
|
||||
operation: "internal_auth_auto_generated",
|
||||
instanceId,
|
||||
envVarName: "INTERNAL_AUTH_TOKEN",
|
||||
note: "Ready for use - no restart required"
|
||||
});
|
||||
databaseLogger.success(
|
||||
"Internal auth token auto-generated and saved to .env",
|
||||
{
|
||||
operation: "internal_auth_auto_generated",
|
||||
instanceId,
|
||||
envVarName: "INTERNAL_AUTH_TOKEN",
|
||||
note: "Ready for use - no restart required",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Validate JWT secret system
|
||||
*/
|
||||
async validateJWTSecret(): Promise<boolean> {
|
||||
try {
|
||||
const secret = await this.getJWTSecret();
|
||||
@@ -230,7 +149,6 @@ class SystemCrypto {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Test JWT operations
|
||||
const jwt = await import("jsonwebtoken");
|
||||
const testPayload = { test: true, timestamp: Date.now() };
|
||||
const token = jwt.default.sign(testPayload, secret, { expiresIn: "1s" });
|
||||
@@ -245,85 +163,62 @@ class SystemCrypto {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get JWT key status (simplified version)
|
||||
*/
|
||||
async getSystemKeyStatus() {
|
||||
const isValid = await this.validateJWTSecret();
|
||||
const hasSecret = this.jwtSecret !== null;
|
||||
|
||||
|
||||
// Check environment variable
|
||||
const hasEnvVar = !!(process.env.JWT_SECRET && process.env.JWT_SECRET.length >= 64);
|
||||
const hasEnvVar = !!(
|
||||
process.env.JWT_SECRET && process.env.JWT_SECRET.length >= 64
|
||||
);
|
||||
|
||||
return {
|
||||
hasSecret,
|
||||
isValid,
|
||||
storage: {
|
||||
environment: hasEnvVar
|
||||
environment: hasEnvVar,
|
||||
},
|
||||
algorithm: "HS256",
|
||||
note: "Using simplified key management without encryption layers"
|
||||
note: "Using simplified key management without encryption layers",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Update .env file with new environment variable
|
||||
*/
|
||||
private async updateEnvFile(key: string, value: string): Promise<void> {
|
||||
// Use data directory for .env file (where database is stored)
|
||||
// This keeps keys and data together in one volume
|
||||
const dataDir = process.env.DATA_DIR || "./db/data";
|
||||
const envPath = path.join(dataDir, ".env");
|
||||
|
||||
try {
|
||||
// Ensure data directory exists
|
||||
await fs.mkdir(dataDir, { recursive: true });
|
||||
|
||||
let envContent = "";
|
||||
|
||||
// Read existing .env file if it exists
|
||||
try {
|
||||
envContent = await fs.readFile(envPath, "utf8");
|
||||
} catch {
|
||||
// File doesn't exist, will create new one
|
||||
envContent = "# Termix Auto-generated Configuration\n\n";
|
||||
}
|
||||
|
||||
// Check if key already exists
|
||||
const keyRegex = new RegExp(`^${key}=.*$`, "m");
|
||||
|
||||
if (keyRegex.test(envContent)) {
|
||||
// Update existing key
|
||||
envContent = envContent.replace(keyRegex, `${key}=${value}`);
|
||||
} else {
|
||||
// Add new key
|
||||
if (!envContent.includes("# Security Keys")) {
|
||||
envContent += "\n# Security Keys (Auto-generated)\n";
|
||||
}
|
||||
envContent += `${key}=${value}\n`;
|
||||
}
|
||||
|
||||
// Write updated content
|
||||
await fs.writeFile(envPath, envContent);
|
||||
|
||||
// Update process.env for current session
|
||||
process.env[key] = value;
|
||||
|
||||
databaseLogger.info(`Environment variable ${key} updated in .env file`, {
|
||||
operation: "env_file_update",
|
||||
key,
|
||||
path: envPath
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
databaseLogger.error(`Failed to update .env file with ${key}`, error, {
|
||||
operation: "env_file_update_failed",
|
||||
key
|
||||
key,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { SystemCrypto };
|
||||
export { SystemCrypto };
|
||||
|
||||
@@ -20,37 +20,29 @@ interface EncryptedDEK {
|
||||
}
|
||||
|
||||
interface UserSession {
|
||||
dataKey: Buffer; // Store DEK directly, delete just-in-time fantasy
|
||||
dataKey: Buffer;
|
||||
lastActivity: number;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* UserCrypto - Simple direct user encryption
|
||||
*
|
||||
* Linus principles:
|
||||
* - Delete just-in-time fantasy, cache DEK directly
|
||||
* - Reasonable 24-hour timeout with 6-hour inactivity, not 5-minute user experience disaster
|
||||
* - Simple working implementation, not theoretically perfect garbage
|
||||
* - Server restart invalidates sessions (this is reasonable)
|
||||
*/
|
||||
class UserCrypto {
|
||||
private static instance: UserCrypto;
|
||||
private userSessions: Map<string, UserSession> = new Map();
|
||||
private sessionExpiredCallback?: (userId: string) => void; // Callback for session expiration
|
||||
private sessionExpiredCallback?: (userId: string) => void;
|
||||
|
||||
// Configuration constants - reasonable timeout settings
|
||||
private static readonly PBKDF2_ITERATIONS = 100000;
|
||||
private static readonly KEK_LENGTH = 32;
|
||||
private static readonly DEK_LENGTH = 32;
|
||||
private static readonly SESSION_DURATION = 24 * 60 * 60 * 1000; // 24 hours, full day session
|
||||
private static readonly MAX_INACTIVITY = 6 * 60 * 60 * 1000; // 6 hours, reasonable inactivity timeout
|
||||
private static readonly SESSION_DURATION = 24 * 60 * 60 * 1000;
|
||||
private static readonly MAX_INACTIVITY = 6 * 60 * 60 * 1000;
|
||||
|
||||
private constructor() {
|
||||
// Reasonable cleanup interval
|
||||
setInterval(() => {
|
||||
this.cleanupExpiredSessions();
|
||||
}, 5 * 60 * 1000); // Clean every 5 minutes, not 30 seconds
|
||||
setInterval(
|
||||
() => {
|
||||
this.cleanupExpiredSessions();
|
||||
},
|
||||
5 * 60 * 1000,
|
||||
);
|
||||
}
|
||||
|
||||
static getInstance(): UserCrypto {
|
||||
@@ -60,16 +52,10 @@ class UserCrypto {
|
||||
return this.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set callback for session expiration (used by AuthManager)
|
||||
*/
|
||||
setSessionExpiredCallback(callback: (userId: string) => void): void {
|
||||
this.sessionExpiredCallback = callback;
|
||||
}
|
||||
|
||||
/**
|
||||
* User registration: generate KEK salt and DEK
|
||||
*/
|
||||
async setupUserEncryption(userId: string, password: string): Promise<void> {
|
||||
const kekSalt = await this.generateKEKSalt();
|
||||
await this.storeKEKSalt(userId, kekSalt);
|
||||
@@ -79,23 +65,12 @@ class UserCrypto {
|
||||
const encryptedDEK = this.encryptDEK(DEK, KEK);
|
||||
await this.storeEncryptedDEK(userId, encryptedDEK);
|
||||
|
||||
// Immediately clean temporary keys
|
||||
KEK.fill(0);
|
||||
DEK.fill(0);
|
||||
|
||||
databaseLogger.success("User encryption setup completed", {
|
||||
operation: "user_crypto_setup",
|
||||
userId,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* User authentication: validate password and cache DEK
|
||||
* Deleted just-in-time fantasy, works directly
|
||||
*/
|
||||
async authenticateUser(userId: string, password: string): Promise<boolean> {
|
||||
try {
|
||||
// Validate password and decrypt DEK
|
||||
const kekSalt = await this.getKEKSalt(userId);
|
||||
if (!kekSalt) return false;
|
||||
|
||||
@@ -107,40 +82,31 @@ class UserCrypto {
|
||||
}
|
||||
|
||||
const DEK = this.decryptDEK(encryptedDEK, KEK);
|
||||
KEK.fill(0); // Immediately clean KEK
|
||||
KEK.fill(0);
|
||||
|
||||
// Debug: Check DEK validity
|
||||
if (!DEK || DEK.length === 0) {
|
||||
databaseLogger.error("DEK is empty or invalid after decryption", {
|
||||
operation: "user_crypto_auth_debug",
|
||||
userId,
|
||||
dekLength: DEK ? DEK.length : 0
|
||||
dekLength: DEK ? DEK.length : 0,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
// Create user session, cache DEK directly
|
||||
const now = Date.now();
|
||||
|
||||
// Clean old session
|
||||
const oldSession = this.userSessions.get(userId);
|
||||
if (oldSession) {
|
||||
oldSession.dataKey.fill(0);
|
||||
}
|
||||
|
||||
this.userSessions.set(userId, {
|
||||
dataKey: Buffer.from(DEK), // Create proper Buffer copy
|
||||
dataKey: Buffer.from(DEK),
|
||||
lastActivity: now,
|
||||
expiresAt: now + UserCrypto.SESSION_DURATION,
|
||||
});
|
||||
|
||||
DEK.fill(0); // Clean temporary DEK
|
||||
|
||||
databaseLogger.success("User authenticated and DEK cached", {
|
||||
operation: "user_crypto_auth",
|
||||
userId,
|
||||
duration: UserCrypto.SESSION_DURATION,
|
||||
});
|
||||
DEK.fill(0);
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
@@ -153,10 +119,6 @@ class UserCrypto {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user data key - simple direct return from cache
|
||||
* Deleted just-in-time derivation garbage
|
||||
*/
|
||||
getUserDataKey(userId: string): Buffer | null {
|
||||
const session = this.userSessions.get(userId);
|
||||
if (!session) {
|
||||
@@ -165,74 +127,49 @@ class UserCrypto {
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
// Check if session has expired
|
||||
if (now > session.expiresAt) {
|
||||
this.userSessions.delete(userId);
|
||||
session.dataKey.fill(0);
|
||||
databaseLogger.info("User session expired", {
|
||||
operation: "user_session_expired",
|
||||
userId,
|
||||
});
|
||||
// Trigger callback to invalidate JWT tokens
|
||||
if (this.sessionExpiredCallback) {
|
||||
this.sessionExpiredCallback(userId);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if max inactivity time exceeded
|
||||
if (now - session.lastActivity > UserCrypto.MAX_INACTIVITY) {
|
||||
this.userSessions.delete(userId);
|
||||
session.dataKey.fill(0);
|
||||
databaseLogger.info("User session inactive timeout", {
|
||||
operation: "user_session_inactive",
|
||||
userId,
|
||||
});
|
||||
// Trigger callback to invalidate JWT tokens
|
||||
if (this.sessionExpiredCallback) {
|
||||
this.sessionExpiredCallback(userId);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Update last activity time
|
||||
session.lastActivity = now;
|
||||
return session.dataKey;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* User logout: clear session
|
||||
*/
|
||||
logoutUser(userId: string): void {
|
||||
const session = this.userSessions.get(userId);
|
||||
if (session) {
|
||||
session.dataKey.fill(0); // Securely clear key
|
||||
session.dataKey.fill(0);
|
||||
this.userSessions.delete(userId);
|
||||
}
|
||||
databaseLogger.info("User logged out", {
|
||||
operation: "user_crypto_logout",
|
||||
userId,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user is unlocked
|
||||
*/
|
||||
isUserUnlocked(userId: string): boolean {
|
||||
return this.getUserDataKey(userId) !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Change user password
|
||||
*/
|
||||
async changeUserPassword(userId: string, oldPassword: string, newPassword: string): Promise<boolean> {
|
||||
async changeUserPassword(
|
||||
userId: string,
|
||||
oldPassword: string,
|
||||
newPassword: string,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
// Validate old password
|
||||
const isValid = await this.validatePassword(userId, oldPassword);
|
||||
if (!isValid) return false;
|
||||
|
||||
// Get current DEK
|
||||
const kekSalt = await this.getKEKSalt(userId);
|
||||
if (!kekSalt) return false;
|
||||
|
||||
@@ -242,21 +179,17 @@ class UserCrypto {
|
||||
|
||||
const DEK = this.decryptDEK(encryptedDEK, oldKEK);
|
||||
|
||||
// Generate new KEK salt and encrypt DEK
|
||||
const newKekSalt = await this.generateKEKSalt();
|
||||
const newKEK = this.deriveKEK(newPassword, newKekSalt);
|
||||
const newEncryptedDEK = this.encryptDEK(DEK, newKEK);
|
||||
|
||||
// Store new salt and encrypted DEK
|
||||
await this.storeKEKSalt(userId, newKekSalt);
|
||||
await this.storeEncryptedDEK(userId, newEncryptedDEK);
|
||||
|
||||
// Clean all temporary keys
|
||||
oldKEK.fill(0);
|
||||
newKEK.fill(0);
|
||||
DEK.fill(0);
|
||||
|
||||
// Clean user session, require re-login
|
||||
this.logoutUser(userId);
|
||||
|
||||
return true;
|
||||
@@ -265,9 +198,10 @@ class UserCrypto {
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Private methods =====
|
||||
|
||||
private async validatePassword(userId: string, password: string): Promise<boolean> {
|
||||
private async validatePassword(
|
||||
userId: string,
|
||||
password: string,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const kekSalt = await this.getKEKSalt(userId);
|
||||
if (!kekSalt) return false;
|
||||
@@ -278,7 +212,6 @@ class UserCrypto {
|
||||
|
||||
const DEK = this.decryptDEK(encryptedDEK, KEK);
|
||||
|
||||
// Clean temporary keys
|
||||
KEK.fill(0);
|
||||
DEK.fill(0);
|
||||
|
||||
@@ -293,26 +226,20 @@ class UserCrypto {
|
||||
const expiredUsers: string[] = [];
|
||||
|
||||
for (const [userId, session] of this.userSessions.entries()) {
|
||||
if (now > session.expiresAt || now - session.lastActivity > UserCrypto.MAX_INACTIVITY) {
|
||||
session.dataKey.fill(0); // Securely clear key
|
||||
if (
|
||||
now > session.expiresAt ||
|
||||
now - session.lastActivity > UserCrypto.MAX_INACTIVITY
|
||||
) {
|
||||
session.dataKey.fill(0);
|
||||
expiredUsers.push(userId);
|
||||
}
|
||||
}
|
||||
|
||||
expiredUsers.forEach(userId => {
|
||||
expiredUsers.forEach((userId) => {
|
||||
this.userSessions.delete(userId);
|
||||
});
|
||||
|
||||
if (expiredUsers.length > 0) {
|
||||
databaseLogger.info(`Cleaned up ${expiredUsers.length} expired sessions`, {
|
||||
operation: "session_cleanup",
|
||||
count: expiredUsers.length,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Database operations and encryption methods (simplified version) =====
|
||||
|
||||
private async generateKEKSalt(): Promise<KEKSalt> {
|
||||
return {
|
||||
salt: crypto.randomBytes(32).toString("hex"),
|
||||
@@ -328,7 +255,7 @@ class UserCrypto {
|
||||
Buffer.from(kekSalt.salt, "hex"),
|
||||
kekSalt.iterations,
|
||||
UserCrypto.KEK_LENGTH,
|
||||
"sha256"
|
||||
"sha256",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -353,7 +280,7 @@ class UserCrypto {
|
||||
const decipher = crypto.createDecipheriv(
|
||||
"aes-256-gcm",
|
||||
kek,
|
||||
Buffer.from(encryptedDEK.iv, "hex")
|
||||
Buffer.from(encryptedDEK.iv, "hex"),
|
||||
);
|
||||
|
||||
decipher.setAuthTag(Buffer.from(encryptedDEK.tag, "hex"));
|
||||
@@ -363,15 +290,20 @@ class UserCrypto {
|
||||
return decrypted;
|
||||
}
|
||||
|
||||
// Database operation methods
|
||||
private async storeKEKSalt(userId: string, kekSalt: KEKSalt): Promise<void> {
|
||||
const key = `user_kek_salt_${userId}`;
|
||||
const value = JSON.stringify(kekSalt);
|
||||
|
||||
const existing = await getDb().select().from(settings).where(eq(settings.key, key));
|
||||
const existing = await getDb()
|
||||
.select()
|
||||
.from(settings)
|
||||
.where(eq(settings.key, key));
|
||||
|
||||
if (existing.length > 0) {
|
||||
await getDb().update(settings).set({ value }).where(eq(settings.key, key));
|
||||
await getDb()
|
||||
.update(settings)
|
||||
.set({ value })
|
||||
.where(eq(settings.key, key));
|
||||
} else {
|
||||
await getDb().insert(settings).values({ key, value });
|
||||
}
|
||||
@@ -380,7 +312,10 @@ class UserCrypto {
|
||||
private async getKEKSalt(userId: string): Promise<KEKSalt | null> {
|
||||
try {
|
||||
const key = `user_kek_salt_${userId}`;
|
||||
const result = await getDb().select().from(settings).where(eq(settings.key, key));
|
||||
const result = await getDb()
|
||||
.select()
|
||||
.from(settings)
|
||||
.where(eq(settings.key, key));
|
||||
|
||||
if (result.length === 0) {
|
||||
return null;
|
||||
@@ -392,14 +327,23 @@ class UserCrypto {
|
||||
}
|
||||
}
|
||||
|
||||
private async storeEncryptedDEK(userId: string, encryptedDEK: EncryptedDEK): Promise<void> {
|
||||
private async storeEncryptedDEK(
|
||||
userId: string,
|
||||
encryptedDEK: EncryptedDEK,
|
||||
): Promise<void> {
|
||||
const key = `user_encrypted_dek_${userId}`;
|
||||
const value = JSON.stringify(encryptedDEK);
|
||||
|
||||
const existing = await getDb().select().from(settings).where(eq(settings.key, key));
|
||||
const existing = await getDb()
|
||||
.select()
|
||||
.from(settings)
|
||||
.where(eq(settings.key, key));
|
||||
|
||||
if (existing.length > 0) {
|
||||
await getDb().update(settings).set({ value }).where(eq(settings.key, key));
|
||||
await getDb()
|
||||
.update(settings)
|
||||
.set({ value })
|
||||
.where(eq(settings.key, key));
|
||||
} else {
|
||||
await getDb().insert(settings).values({ key, value });
|
||||
}
|
||||
@@ -408,7 +352,10 @@ class UserCrypto {
|
||||
private async getEncryptedDEK(userId: string): Promise<EncryptedDEK | null> {
|
||||
try {
|
||||
const key = `user_encrypted_dek_${userId}`;
|
||||
const result = await getDb().select().from(settings).where(eq(settings.key, key));
|
||||
const result = await getDb()
|
||||
.select()
|
||||
.from(settings)
|
||||
.where(eq(settings.key, key));
|
||||
|
||||
if (result.length === 0) {
|
||||
return null;
|
||||
@@ -419,7 +366,6 @@ class UserCrypto {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export { UserCrypto, type KEKSalt, type EncryptedDEK };
|
||||
export { UserCrypto, type KEKSalt, type EncryptedDEK };
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
import { getDb } from "../database/db/index.js";
|
||||
import { users, sshData, sshCredentials, fileManagerRecent, fileManagerPinned, fileManagerShortcuts, dismissedAlerts } from "../database/db/schema.js";
|
||||
import {
|
||||
users,
|
||||
sshData,
|
||||
sshCredentials,
|
||||
fileManagerRecent,
|
||||
fileManagerPinned,
|
||||
fileManagerShortcuts,
|
||||
dismissedAlerts,
|
||||
} from "../database/db/schema.js";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { DataCrypto } from "./data-crypto.js";
|
||||
import { databaseLogger } from "./logger.js";
|
||||
import crypto from "crypto";
|
||||
|
||||
interface UserExportData {
|
||||
version: string;
|
||||
@@ -23,87 +30,98 @@ interface UserExportData {
|
||||
metadata: {
|
||||
totalRecords: number;
|
||||
encrypted: boolean;
|
||||
exportType: 'user_data' | 'system_config' | 'all';
|
||||
exportType: "user_data" | "system_config" | "all";
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* UserDataExport - User-level data import/export
|
||||
*
|
||||
* Linus principles:
|
||||
* - Users own their data and should be able to export freely
|
||||
* - Simple and direct, no complex permission checks
|
||||
* - Support both encrypted and plaintext formats
|
||||
* - Don't break existing system architecture
|
||||
*/
|
||||
class UserDataExport {
|
||||
private static readonly EXPORT_VERSION = "v2.0";
|
||||
|
||||
/**
|
||||
* Export user data
|
||||
*/
|
||||
static async exportUserData(
|
||||
userId: string,
|
||||
options: {
|
||||
format?: 'encrypted' | 'plaintext';
|
||||
scope?: 'user_data' | 'all';
|
||||
format?: "encrypted" | "plaintext";
|
||||
scope?: "user_data" | "all";
|
||||
includeCredentials?: boolean;
|
||||
} = {}
|
||||
} = {},
|
||||
): Promise<UserExportData> {
|
||||
const { format = 'encrypted', scope = 'user_data', includeCredentials = true } = options;
|
||||
const {
|
||||
format = "encrypted",
|
||||
scope = "user_data",
|
||||
includeCredentials = true,
|
||||
} = options;
|
||||
|
||||
try {
|
||||
databaseLogger.info("Starting user data export", {
|
||||
operation: "user_data_export",
|
||||
userId,
|
||||
format,
|
||||
scope,
|
||||
includeCredentials,
|
||||
});
|
||||
|
||||
// Verify user exists
|
||||
const user = await getDb().select().from(users).where(eq(users.id, userId));
|
||||
const user = await getDb()
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.id, userId));
|
||||
if (!user || user.length === 0) {
|
||||
throw new Error(`User not found: ${userId}`);
|
||||
}
|
||||
|
||||
const userRecord = user[0];
|
||||
|
||||
// Get user data key (if decryption needed)
|
||||
let userDataKey: Buffer | null = null;
|
||||
if (format === 'plaintext') {
|
||||
if (format === "plaintext") {
|
||||
userDataKey = DataCrypto.getUserDataKey(userId);
|
||||
if (!userDataKey) {
|
||||
throw new Error("User data not unlocked - password required for plaintext export");
|
||||
throw new Error(
|
||||
"User data not unlocked - password required for plaintext export",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Export SSH host configurations
|
||||
const sshHosts = await getDb().select().from(sshData).where(eq(sshData.userId, userId));
|
||||
const processedSshHosts = format === 'plaintext' && userDataKey
|
||||
? sshHosts.map(host => DataCrypto.decryptRecord("ssh_data", host, userId, userDataKey!))
|
||||
: sshHosts;
|
||||
const sshHosts = await getDb()
|
||||
.select()
|
||||
.from(sshData)
|
||||
.where(eq(sshData.userId, userId));
|
||||
const processedSshHosts =
|
||||
format === "plaintext" && userDataKey
|
||||
? sshHosts.map((host) =>
|
||||
DataCrypto.decryptRecord("ssh_data", host, userId, userDataKey!),
|
||||
)
|
||||
: sshHosts;
|
||||
|
||||
// Export SSH credentials (if included)
|
||||
let sshCredentialsData: any[] = [];
|
||||
if (includeCredentials) {
|
||||
const credentials = await getDb().select().from(sshCredentials).where(eq(sshCredentials.userId, userId));
|
||||
sshCredentialsData = format === 'plaintext' && userDataKey
|
||||
? credentials.map(cred => DataCrypto.decryptRecord("ssh_credentials", cred, userId, userDataKey!))
|
||||
: credentials;
|
||||
const credentials = await getDb()
|
||||
.select()
|
||||
.from(sshCredentials)
|
||||
.where(eq(sshCredentials.userId, userId));
|
||||
sshCredentialsData =
|
||||
format === "plaintext" && userDataKey
|
||||
? credentials.map((cred) =>
|
||||
DataCrypto.decryptRecord(
|
||||
"ssh_credentials",
|
||||
cred,
|
||||
userId,
|
||||
userDataKey!,
|
||||
),
|
||||
)
|
||||
: credentials;
|
||||
}
|
||||
|
||||
// Export file manager data
|
||||
const [recentFiles, pinnedFiles, shortcuts] = await Promise.all([
|
||||
getDb().select().from(fileManagerRecent).where(eq(fileManagerRecent.userId, userId)),
|
||||
getDb().select().from(fileManagerPinned).where(eq(fileManagerPinned.userId, userId)),
|
||||
getDb().select().from(fileManagerShortcuts).where(eq(fileManagerShortcuts.userId, userId)),
|
||||
getDb()
|
||||
.select()
|
||||
.from(fileManagerRecent)
|
||||
.where(eq(fileManagerRecent.userId, userId)),
|
||||
getDb()
|
||||
.select()
|
||||
.from(fileManagerPinned)
|
||||
.where(eq(fileManagerPinned.userId, userId)),
|
||||
getDb()
|
||||
.select()
|
||||
.from(fileManagerShortcuts)
|
||||
.where(eq(fileManagerShortcuts.userId, userId)),
|
||||
]);
|
||||
|
||||
// Export dismissed alerts
|
||||
const alerts = await getDb().select().from(dismissedAlerts).where(eq(dismissedAlerts.userId, userId));
|
||||
const alerts = await getDb()
|
||||
.select()
|
||||
.from(dismissedAlerts)
|
||||
.where(eq(dismissedAlerts.userId, userId));
|
||||
|
||||
// Build export data
|
||||
const exportData: UserExportData = {
|
||||
version: this.EXPORT_VERSION,
|
||||
exportedAt: new Date().toISOString(),
|
||||
@@ -120,8 +138,14 @@ class UserDataExport {
|
||||
dismissedAlerts: alerts,
|
||||
},
|
||||
metadata: {
|
||||
totalRecords: processedSshHosts.length + sshCredentialsData.length + recentFiles.length + pinnedFiles.length + shortcuts.length + alerts.length,
|
||||
encrypted: format === 'encrypted',
|
||||
totalRecords:
|
||||
processedSshHosts.length +
|
||||
sshCredentialsData.length +
|
||||
recentFiles.length +
|
||||
pinnedFiles.length +
|
||||
shortcuts.length +
|
||||
alerts.length,
|
||||
encrypted: format === "encrypted",
|
||||
exportType: scope,
|
||||
},
|
||||
};
|
||||
@@ -147,30 +171,24 @@ class UserDataExport {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Export as JSON string
|
||||
*/
|
||||
static async exportUserDataToJSON(
|
||||
userId: string,
|
||||
options: {
|
||||
format?: 'encrypted' | 'plaintext';
|
||||
scope?: 'user_data' | 'all';
|
||||
format?: "encrypted" | "plaintext";
|
||||
scope?: "user_data" | "all";
|
||||
includeCredentials?: boolean;
|
||||
pretty?: boolean;
|
||||
} = {}
|
||||
} = {},
|
||||
): Promise<string> {
|
||||
const { pretty = true } = options;
|
||||
const exportData = await this.exportUserData(userId, options);
|
||||
return JSON.stringify(exportData, null, pretty ? 2 : 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate export data format
|
||||
*/
|
||||
static validateExportData(data: any): { valid: boolean; errors: string[] } {
|
||||
const errors: string[] = [];
|
||||
|
||||
if (!data || typeof data !== 'object') {
|
||||
if (!data || typeof data !== "object") {
|
||||
errors.push("Export data must be an object");
|
||||
return { valid: false, errors };
|
||||
}
|
||||
@@ -183,28 +201,43 @@ class UserDataExport {
|
||||
errors.push("Missing userId field");
|
||||
}
|
||||
|
||||
if (!data.userData || typeof data.userData !== 'object') {
|
||||
if (!data.userData || typeof data.userData !== "object") {
|
||||
errors.push("Missing or invalid userData field");
|
||||
}
|
||||
|
||||
if (!data.metadata || typeof data.metadata !== 'object') {
|
||||
if (!data.metadata || typeof data.metadata !== "object") {
|
||||
errors.push("Missing or invalid metadata field");
|
||||
}
|
||||
|
||||
// Check required data fields
|
||||
if (data.userData) {
|
||||
const requiredFields = ['sshHosts', 'sshCredentials', 'fileManagerData', 'dismissedAlerts'];
|
||||
const requiredFields = [
|
||||
"sshHosts",
|
||||
"sshCredentials",
|
||||
"fileManagerData",
|
||||
"dismissedAlerts",
|
||||
];
|
||||
for (const field of requiredFields) {
|
||||
if (!Array.isArray(data.userData[field]) && !(field === 'fileManagerData' && typeof data.userData[field] === 'object')) {
|
||||
if (
|
||||
!Array.isArray(data.userData[field]) &&
|
||||
!(
|
||||
field === "fileManagerData" &&
|
||||
typeof data.userData[field] === "object"
|
||||
)
|
||||
) {
|
||||
errors.push(`Missing or invalid userData.${field} field`);
|
||||
}
|
||||
}
|
||||
|
||||
if (data.userData.fileManagerData && typeof data.userData.fileManagerData === 'object') {
|
||||
const fmFields = ['recent', 'pinned', 'shortcuts'];
|
||||
if (
|
||||
data.userData.fileManagerData &&
|
||||
typeof data.userData.fileManagerData === "object"
|
||||
) {
|
||||
const fmFields = ["recent", "pinned", "shortcuts"];
|
||||
for (const field of fmFields) {
|
||||
if (!Array.isArray(data.userData.fileManagerData[field])) {
|
||||
errors.push(`Missing or invalid userData.fileManagerData.${field} field`);
|
||||
errors.push(
|
||||
`Missing or invalid userData.fileManagerData.${field} field`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -213,9 +246,6 @@ class UserDataExport {
|
||||
return { valid: errors.length === 0, errors };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get export data statistics
|
||||
*/
|
||||
static getExportStats(data: UserExportData): {
|
||||
version: string;
|
||||
exportedAt: string;
|
||||
@@ -237,9 +267,10 @@ class UserDataExport {
|
||||
breakdown: {
|
||||
sshHosts: data.userData.sshHosts.length,
|
||||
sshCredentials: data.userData.sshCredentials.length,
|
||||
fileManagerItems: data.userData.fileManagerData.recent.length +
|
||||
data.userData.fileManagerData.pinned.length +
|
||||
data.userData.fileManagerData.shortcuts.length,
|
||||
fileManagerItems:
|
||||
data.userData.fileManagerData.recent.length +
|
||||
data.userData.fileManagerData.pinned.length +
|
||||
data.userData.fileManagerData.shortcuts.length,
|
||||
dismissedAlerts: data.userData.dismissedAlerts.length,
|
||||
},
|
||||
encrypted: data.metadata.encrypted,
|
||||
@@ -247,4 +278,4 @@ class UserDataExport {
|
||||
}
|
||||
}
|
||||
|
||||
export { UserDataExport, type UserExportData };
|
||||
export { UserDataExport, type UserExportData };
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import { getDb } from "../database/db/index.js";
|
||||
import { users, sshData, sshCredentials, fileManagerRecent, fileManagerPinned, fileManagerShortcuts, dismissedAlerts } from "../database/db/schema.js";
|
||||
import {
|
||||
users,
|
||||
sshData,
|
||||
sshCredentials,
|
||||
fileManagerRecent,
|
||||
fileManagerPinned,
|
||||
fileManagerShortcuts,
|
||||
dismissedAlerts,
|
||||
} from "../database/db/schema.js";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { DataCrypto } from "./data-crypto.js";
|
||||
import { UserDataExport, type UserExportData } from "./user-data-export.js";
|
||||
@@ -26,62 +34,40 @@ interface ImportResult {
|
||||
dryRun: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* UserDataImport - User data import
|
||||
*
|
||||
* Linus principles:
|
||||
* - Import should not break existing data (unless explicitly requested)
|
||||
* - Support dry-run mode for validation
|
||||
* - Simple strategy for ID conflicts: regenerate
|
||||
* - Error handling must be explicit, no silent failures
|
||||
*/
|
||||
class UserDataImport {
|
||||
|
||||
/**
|
||||
* Import user data
|
||||
*/
|
||||
static async importUserData(
|
||||
targetUserId: string,
|
||||
exportData: UserExportData,
|
||||
options: ImportOptions = {}
|
||||
options: ImportOptions = {},
|
||||
): Promise<ImportResult> {
|
||||
const {
|
||||
replaceExisting = false,
|
||||
skipCredentials = false,
|
||||
skipFileManagerData = false,
|
||||
dryRun = false
|
||||
dryRun = false,
|
||||
} = options;
|
||||
|
||||
try {
|
||||
databaseLogger.info("Starting user data import", {
|
||||
operation: "user_data_import",
|
||||
targetUserId,
|
||||
sourceUserId: exportData.userId,
|
||||
sourceUsername: exportData.username,
|
||||
dryRun,
|
||||
replaceExisting,
|
||||
skipCredentials,
|
||||
skipFileManagerData,
|
||||
});
|
||||
|
||||
// Verify target user exists
|
||||
const targetUser = await getDb().select().from(users).where(eq(users.id, targetUserId));
|
||||
const targetUser = await getDb()
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.id, targetUserId));
|
||||
if (!targetUser || targetUser.length === 0) {
|
||||
throw new Error(`Target user not found: ${targetUserId}`);
|
||||
}
|
||||
|
||||
// Validate export data format
|
||||
const validation = UserDataExport.validateExportData(exportData);
|
||||
if (!validation.valid) {
|
||||
throw new Error(`Invalid export data: ${validation.errors.join(', ')}`);
|
||||
throw new Error(`Invalid export data: ${validation.errors.join(", ")}`);
|
||||
}
|
||||
|
||||
// Verify user data is unlocked (if data is encrypted)
|
||||
let userDataKey: Buffer | null = null;
|
||||
if (exportData.metadata.encrypted) {
|
||||
userDataKey = DataCrypto.getUserDataKey(targetUserId);
|
||||
if (!userDataKey) {
|
||||
throw new Error("Target user data not unlocked - password required for encrypted import");
|
||||
throw new Error(
|
||||
"Target user data not unlocked - password required for encrypted import",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,48 +84,54 @@ class UserDataImport {
|
||||
dryRun,
|
||||
};
|
||||
|
||||
// Import SSH host configurations
|
||||
if (exportData.userData.sshHosts && exportData.userData.sshHosts.length > 0) {
|
||||
if (
|
||||
exportData.userData.sshHosts &&
|
||||
exportData.userData.sshHosts.length > 0
|
||||
) {
|
||||
const importStats = await this.importSshHosts(
|
||||
targetUserId,
|
||||
exportData.userData.sshHosts,
|
||||
{ replaceExisting, dryRun, userDataKey }
|
||||
{ replaceExisting, dryRun, userDataKey },
|
||||
);
|
||||
result.summary.sshHostsImported = importStats.imported;
|
||||
result.summary.skippedItems += importStats.skipped;
|
||||
result.summary.errors.push(...importStats.errors);
|
||||
}
|
||||
|
||||
// Import SSH credentials
|
||||
if (!skipCredentials && exportData.userData.sshCredentials && exportData.userData.sshCredentials.length > 0) {
|
||||
if (
|
||||
!skipCredentials &&
|
||||
exportData.userData.sshCredentials &&
|
||||
exportData.userData.sshCredentials.length > 0
|
||||
) {
|
||||
const importStats = await this.importSshCredentials(
|
||||
targetUserId,
|
||||
exportData.userData.sshCredentials,
|
||||
{ replaceExisting, dryRun, userDataKey }
|
||||
{ replaceExisting, dryRun, userDataKey },
|
||||
);
|
||||
result.summary.sshCredentialsImported = importStats.imported;
|
||||
result.summary.skippedItems += importStats.skipped;
|
||||
result.summary.errors.push(...importStats.errors);
|
||||
}
|
||||
|
||||
// Import file manager data
|
||||
if (!skipFileManagerData && exportData.userData.fileManagerData) {
|
||||
const importStats = await this.importFileManagerData(
|
||||
targetUserId,
|
||||
exportData.userData.fileManagerData,
|
||||
{ replaceExisting, dryRun }
|
||||
{ replaceExisting, dryRun },
|
||||
);
|
||||
result.summary.fileManagerItemsImported = importStats.imported;
|
||||
result.summary.skippedItems += importStats.skipped;
|
||||
result.summary.errors.push(...importStats.errors);
|
||||
}
|
||||
|
||||
// Import dismissed alerts
|
||||
if (exportData.userData.dismissedAlerts && exportData.userData.dismissedAlerts.length > 0) {
|
||||
if (
|
||||
exportData.userData.dismissedAlerts &&
|
||||
exportData.userData.dismissedAlerts.length > 0
|
||||
) {
|
||||
const importStats = await this.importDismissedAlerts(
|
||||
targetUserId,
|
||||
exportData.userData.dismissedAlerts,
|
||||
{ replaceExisting, dryRun }
|
||||
{ replaceExisting, dryRun },
|
||||
);
|
||||
result.summary.dismissedAlertsImported = importStats.imported;
|
||||
result.summary.skippedItems += importStats.skipped;
|
||||
@@ -166,13 +158,14 @@ class UserDataImport {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Import SSH host configurations
|
||||
*/
|
||||
private static async importSshHosts(
|
||||
targetUserId: string,
|
||||
sshHosts: any[],
|
||||
options: { replaceExisting: boolean; dryRun: boolean; userDataKey: Buffer | null }
|
||||
options: {
|
||||
replaceExisting: boolean;
|
||||
dryRun: boolean;
|
||||
userDataKey: Buffer | null;
|
||||
},
|
||||
) {
|
||||
let imported = 0;
|
||||
let skipped = 0;
|
||||
@@ -185,29 +178,33 @@ class UserDataImport {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Generate temporary ID for encryption context, then remove for database insert
|
||||
const tempId = `import-ssh-${targetUserId}-${Date.now()}-${imported}`;
|
||||
const newHostData = {
|
||||
...host,
|
||||
id: tempId, // Temporary ID for encryption context
|
||||
id: tempId,
|
||||
userId: targetUserId,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
// If data needs re-encryption
|
||||
let processedHostData = newHostData;
|
||||
if (options.userDataKey) {
|
||||
processedHostData = DataCrypto.encryptRecord("ssh_data", newHostData, targetUserId, options.userDataKey);
|
||||
processedHostData = DataCrypto.encryptRecord(
|
||||
"ssh_data",
|
||||
newHostData,
|
||||
targetUserId,
|
||||
options.userDataKey,
|
||||
);
|
||||
}
|
||||
|
||||
// Remove temp ID to let database auto-generate real ID
|
||||
delete processedHostData.id;
|
||||
|
||||
await getDb().insert(sshData).values(processedHostData);
|
||||
imported++;
|
||||
} catch (error) {
|
||||
errors.push(`SSH host import failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
errors.push(
|
||||
`SSH host import failed: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
);
|
||||
skipped++;
|
||||
}
|
||||
}
|
||||
@@ -215,13 +212,14 @@ class UserDataImport {
|
||||
return { imported, skipped, errors };
|
||||
}
|
||||
|
||||
/**
|
||||
* Import SSH credentials
|
||||
*/
|
||||
private static async importSshCredentials(
|
||||
targetUserId: string,
|
||||
credentials: any[],
|
||||
options: { replaceExisting: boolean; dryRun: boolean; userDataKey: Buffer | null }
|
||||
options: {
|
||||
replaceExisting: boolean;
|
||||
dryRun: boolean;
|
||||
userDataKey: Buffer | null;
|
||||
},
|
||||
) {
|
||||
let imported = 0;
|
||||
let skipped = 0;
|
||||
@@ -234,31 +232,35 @@ class UserDataImport {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Generate temporary ID for encryption context, then remove for database insert
|
||||
const tempCredId = `import-cred-${targetUserId}-${Date.now()}-${imported}`;
|
||||
const newCredentialData = {
|
||||
...credential,
|
||||
id: tempCredId, // Temporary ID for encryption context
|
||||
id: tempCredId,
|
||||
userId: targetUserId,
|
||||
usageCount: 0, // Reset usage count
|
||||
usageCount: 0,
|
||||
lastUsed: null,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
// If data needs re-encryption
|
||||
let processedCredentialData = newCredentialData;
|
||||
if (options.userDataKey) {
|
||||
processedCredentialData = DataCrypto.encryptRecord("ssh_credentials", newCredentialData, targetUserId, options.userDataKey);
|
||||
processedCredentialData = DataCrypto.encryptRecord(
|
||||
"ssh_credentials",
|
||||
newCredentialData,
|
||||
targetUserId,
|
||||
options.userDataKey,
|
||||
);
|
||||
}
|
||||
|
||||
// Remove temp ID to let database auto-generate real ID
|
||||
delete processedCredentialData.id;
|
||||
|
||||
await getDb().insert(sshCredentials).values(processedCredentialData);
|
||||
imported++;
|
||||
} catch (error) {
|
||||
errors.push(`SSH credential import failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
errors.push(
|
||||
`SSH credential import failed: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
);
|
||||
skipped++;
|
||||
}
|
||||
}
|
||||
@@ -266,20 +268,16 @@ class UserDataImport {
|
||||
return { imported, skipped, errors };
|
||||
}
|
||||
|
||||
/**
|
||||
* Import file manager data
|
||||
*/
|
||||
private static async importFileManagerData(
|
||||
targetUserId: string,
|
||||
fileManagerData: any,
|
||||
options: { replaceExisting: boolean; dryRun: boolean }
|
||||
options: { replaceExisting: boolean; dryRun: boolean },
|
||||
) {
|
||||
let imported = 0;
|
||||
let skipped = 0;
|
||||
const errors: string[] = [];
|
||||
|
||||
try {
|
||||
// Import recent files
|
||||
if (fileManagerData.recent && Array.isArray(fileManagerData.recent)) {
|
||||
for (const item of fileManagerData.recent) {
|
||||
try {
|
||||
@@ -294,13 +292,14 @@ class UserDataImport {
|
||||
}
|
||||
imported++;
|
||||
} catch (error) {
|
||||
errors.push(`Recent file import failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
errors.push(
|
||||
`Recent file import failed: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
);
|
||||
skipped++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Import pinned files
|
||||
if (fileManagerData.pinned && Array.isArray(fileManagerData.pinned)) {
|
||||
for (const item of fileManagerData.pinned) {
|
||||
try {
|
||||
@@ -315,14 +314,18 @@ class UserDataImport {
|
||||
}
|
||||
imported++;
|
||||
} catch (error) {
|
||||
errors.push(`Pinned file import failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
errors.push(
|
||||
`Pinned file import failed: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
);
|
||||
skipped++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Import shortcuts
|
||||
if (fileManagerData.shortcuts && Array.isArray(fileManagerData.shortcuts)) {
|
||||
if (
|
||||
fileManagerData.shortcuts &&
|
||||
Array.isArray(fileManagerData.shortcuts)
|
||||
) {
|
||||
for (const item of fileManagerData.shortcuts) {
|
||||
try {
|
||||
if (!options.dryRun) {
|
||||
@@ -336,25 +339,26 @@ class UserDataImport {
|
||||
}
|
||||
imported++;
|
||||
} catch (error) {
|
||||
errors.push(`Shortcut import failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
errors.push(
|
||||
`Shortcut import failed: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
);
|
||||
skipped++;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
errors.push(`File manager data import failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
errors.push(
|
||||
`File manager data import failed: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
);
|
||||
}
|
||||
|
||||
return { imported, skipped, errors };
|
||||
}
|
||||
|
||||
/**
|
||||
* Import dismissed alerts
|
||||
*/
|
||||
private static async importDismissedAlerts(
|
||||
targetUserId: string,
|
||||
alerts: any[],
|
||||
options: { replaceExisting: boolean; dryRun: boolean }
|
||||
options: { replaceExisting: boolean; dryRun: boolean },
|
||||
) {
|
||||
let imported = 0;
|
||||
let skipped = 0;
|
||||
@@ -367,15 +371,14 @@ class UserDataImport {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if alert already exists
|
||||
const existing = await getDb()
|
||||
.select()
|
||||
.from(dismissedAlerts)
|
||||
.where(
|
||||
and(
|
||||
eq(dismissedAlerts.userId, targetUserId),
|
||||
eq(dismissedAlerts.alertId, alert.alertId)
|
||||
)
|
||||
eq(dismissedAlerts.alertId, alert.alertId),
|
||||
),
|
||||
);
|
||||
|
||||
if (existing.length > 0 && !options.replaceExisting) {
|
||||
@@ -401,7 +404,9 @@ class UserDataImport {
|
||||
|
||||
imported++;
|
||||
} catch (error) {
|
||||
errors.push(`Dismissed alert import failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
errors.push(
|
||||
`Dismissed alert import failed: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
);
|
||||
skipped++;
|
||||
}
|
||||
}
|
||||
@@ -409,13 +414,10 @@ class UserDataImport {
|
||||
return { imported, skipped, errors };
|
||||
}
|
||||
|
||||
/**
|
||||
* Import from JSON string
|
||||
*/
|
||||
static async importUserDataFromJSON(
|
||||
targetUserId: string,
|
||||
jsonData: string,
|
||||
options: ImportOptions = {}
|
||||
options: ImportOptions = {},
|
||||
): Promise<ImportResult> {
|
||||
try {
|
||||
const exportData: UserExportData = JSON.parse(jsonData);
|
||||
@@ -429,4 +431,4 @@ class UserDataImport {
|
||||
}
|
||||
}
|
||||
|
||||
export { UserDataImport, type ImportOptions, type ImportResult };
|
||||
export { UserDataImport, type ImportOptions, type ImportResult };
|
||||
|
||||
@@ -52,7 +52,9 @@ export function VersionAlert({
|
||||
<Download className="h-4 w-4" />
|
||||
<AlertTitle>{t("versionCheck.upToDate")}</AlertTitle>
|
||||
<AlertDescription>
|
||||
{t("versionCheck.currentVersion", { version: updateInfo.localVersion })}
|
||||
{t("versionCheck.currentVersion", {
|
||||
version: updateInfo.localVersion,
|
||||
})}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
@@ -70,13 +72,17 @@ export function VersionAlert({
|
||||
latest: updateInfo.remoteVersion,
|
||||
})}
|
||||
</div>
|
||||
|
||||
|
||||
{updateInfo.latest_release && (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
<div className="font-medium">{updateInfo.latest_release.name}</div>
|
||||
<div className="font-medium">
|
||||
{updateInfo.latest_release.name}
|
||||
</div>
|
||||
<div className="text-xs">
|
||||
{t("versionCheck.releasedOn", {
|
||||
date: new Date(updateInfo.latest_release.published_at).toLocaleDateString(),
|
||||
date: new Date(
|
||||
updateInfo.latest_release.published_at,
|
||||
).toLocaleDateString(),
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
@@ -100,7 +106,7 @@ export function VersionAlert({
|
||||
{t("versionCheck.downloadUpdate")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
|
||||
{showDismiss && onDismiss && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
||||
1
src/types/electron.d.ts
vendored
1
src/types/electron.d.ts
vendored
@@ -18,7 +18,6 @@ export interface ElectronAPI {
|
||||
|
||||
invoke: (channel: string, ...args: any[]) => Promise<any>;
|
||||
|
||||
// Drag and drop API
|
||||
createTempFile: (fileData: {
|
||||
fileName: string;
|
||||
content: string;
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
// CENTRAL TYPE DEFINITIONS
|
||||
// ============================================================================
|
||||
// This file contains all shared interfaces and types used across the application
|
||||
// to avoid duplication and ensure consistency.
|
||||
|
||||
import type { Client } from "ssh2";
|
||||
|
||||
@@ -25,7 +24,6 @@ export interface SSHHost {
|
||||
keyPassword?: string;
|
||||
keyType?: string;
|
||||
|
||||
// Autostart plaintext credentials
|
||||
autostartPassword?: string;
|
||||
autostartKey?: string;
|
||||
autostartKeyPassword?: string;
|
||||
|
||||
@@ -91,10 +91,8 @@ export function AdminSettings({
|
||||
null,
|
||||
);
|
||||
|
||||
// Simplified security state
|
||||
const [securityInitialized, setSecurityInitialized] = React.useState(true);
|
||||
|
||||
// Database migration state
|
||||
const [exportLoading, setExportLoading] = React.useState(false);
|
||||
const [importLoading, setImportLoading] = React.useState(false);
|
||||
const [importFile, setImportFile] = React.useState<File | null>(null);
|
||||
@@ -103,9 +101,6 @@ export function AdminSettings({
|
||||
const [importPassword, setImportPassword] = React.useState("");
|
||||
|
||||
React.useEffect(() => {
|
||||
// JWT is now automatically sent via HttpOnly cookies
|
||||
// No need to check for JWT cookie manually
|
||||
|
||||
if (isElectron()) {
|
||||
const serverUrl = (window as any).configuredServerUrl;
|
||||
if (!serverUrl) {
|
||||
@@ -147,9 +142,6 @@ export function AdminSettings({
|
||||
}, []);
|
||||
|
||||
const fetchUsers = async () => {
|
||||
// JWT is now automatically sent via HttpOnly cookies
|
||||
// No need to check for JWT cookie manually
|
||||
|
||||
if (isElectron()) {
|
||||
const serverUrl = (window as any).configuredServerUrl;
|
||||
if (!serverUrl) {
|
||||
@@ -172,7 +164,6 @@ export function AdminSettings({
|
||||
|
||||
const handleToggleRegistration = async (checked: boolean) => {
|
||||
setRegLoading(true);
|
||||
// JWT is now automatically sent via HttpOnly cookies
|
||||
try {
|
||||
await updateRegistrationAllowed(checked);
|
||||
setAllowRegistration(checked);
|
||||
@@ -204,7 +195,6 @@ export function AdminSettings({
|
||||
return;
|
||||
}
|
||||
|
||||
// JWT is now automatically sent via HttpOnly cookies
|
||||
try {
|
||||
await updateOIDCConfig(oidcConfig);
|
||||
toast.success(t("admin.oidcConfigurationUpdated"));
|
||||
@@ -226,7 +216,6 @@ export function AdminSettings({
|
||||
if (!newAdminUsername.trim()) return;
|
||||
setMakeAdminLoading(true);
|
||||
setMakeAdminError(null);
|
||||
// JWT is now automatically sent via HttpOnly cookies
|
||||
try {
|
||||
await makeUserAdmin(newAdminUsername.trim());
|
||||
toast.success(t("admin.userIsNowAdmin", { username: newAdminUsername }));
|
||||
@@ -243,7 +232,6 @@ export function AdminSettings({
|
||||
|
||||
const handleRemoveAdminStatus = async (username: string) => {
|
||||
confirmWithToast(t("admin.removeAdminStatus", { username }), async () => {
|
||||
// JWT is now automatically sent via HttpOnly cookies
|
||||
try {
|
||||
await removeAdminStatus(username);
|
||||
toast.success(t("admin.adminStatusRemoved", { username }));
|
||||
@@ -258,7 +246,6 @@ export function AdminSettings({
|
||||
confirmWithToast(
|
||||
t("admin.deleteUser", { username }),
|
||||
async () => {
|
||||
// JWT is now automatically sent via HttpOnly cookies
|
||||
try {
|
||||
await deleteUser(username);
|
||||
toast.success(t("admin.userDeletedSuccessfully", { username }));
|
||||
@@ -271,14 +258,6 @@ export function AdminSettings({
|
||||
);
|
||||
};
|
||||
|
||||
const checkSecurityStatus = async () => {
|
||||
// New v2-kek-dek system is always initialized
|
||||
setSecurityInitialized(true);
|
||||
};
|
||||
|
||||
|
||||
|
||||
// Database export/import handlers
|
||||
const handleExportDatabase = async () => {
|
||||
if (!showPasswordInput) {
|
||||
setShowPasswordInput(true);
|
||||
@@ -292,7 +271,6 @@ export function AdminSettings({
|
||||
|
||||
setExportLoading(true);
|
||||
try {
|
||||
// JWT is now automatically sent via HttpOnly cookies
|
||||
const apiUrl = isElectron()
|
||||
? `${(window as any).configuredServerUrl}/database/export`
|
||||
: "http://localhost:30001/database/export";
|
||||
@@ -302,18 +280,19 @@ export function AdminSettings({
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
credentials: "include", // Include HttpOnly cookies
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ password: exportPassword }),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
// Handle file download
|
||||
const blob = await response.blob();
|
||||
const contentDisposition = response.headers.get('content-disposition');
|
||||
const filename = contentDisposition?.match(/filename="([^"]+)"/)?.[1] || 'termix-export.sqlite';
|
||||
const contentDisposition = response.headers.get("content-disposition");
|
||||
const filename =
|
||||
contentDisposition?.match(/filename="([^"]+)"/)?.[1] ||
|
||||
"termix-export.sqlite";
|
||||
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
@@ -352,19 +331,17 @@ export function AdminSettings({
|
||||
|
||||
setImportLoading(true);
|
||||
try {
|
||||
// JWT is now automatically sent via HttpOnly cookies
|
||||
const apiUrl = isElectron()
|
||||
? `${(window as any).configuredServerUrl}/database/import`
|
||||
: "http://localhost:30001/database/import";
|
||||
|
||||
// Create FormData for file upload
|
||||
const formData = new FormData();
|
||||
formData.append("file", importFile);
|
||||
formData.append("password", importPassword);
|
||||
|
||||
const response = await fetch(apiUrl, {
|
||||
method: "POST",
|
||||
credentials: "include", // Include HttpOnly cookies
|
||||
credentials: "include",
|
||||
body: formData,
|
||||
});
|
||||
|
||||
@@ -372,23 +349,34 @@ export function AdminSettings({
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
const summary = result.summary;
|
||||
const imported = summary.sshHostsImported + summary.sshCredentialsImported + summary.fileManagerItemsImported + summary.dismissedAlertsImported + (summary.settingsImported || 0);
|
||||
const imported =
|
||||
summary.sshHostsImported +
|
||||
summary.sshCredentialsImported +
|
||||
summary.fileManagerItemsImported +
|
||||
summary.dismissedAlertsImported +
|
||||
(summary.settingsImported || 0);
|
||||
const skipped = summary.skippedItems;
|
||||
|
||||
const details = [];
|
||||
if (summary.sshHostsImported > 0) details.push(`${summary.sshHostsImported} SSH hosts`);
|
||||
if (summary.sshCredentialsImported > 0) details.push(`${summary.sshCredentialsImported} credentials`);
|
||||
if (summary.fileManagerItemsImported > 0) details.push(`${summary.fileManagerItemsImported} file manager items`);
|
||||
if (summary.dismissedAlertsImported > 0) details.push(`${summary.dismissedAlertsImported} alerts`);
|
||||
if (summary.settingsImported > 0) details.push(`${summary.settingsImported} settings`);
|
||||
if (summary.sshHostsImported > 0)
|
||||
details.push(`${summary.sshHostsImported} SSH hosts`);
|
||||
if (summary.sshCredentialsImported > 0)
|
||||
details.push(`${summary.sshCredentialsImported} credentials`);
|
||||
if (summary.fileManagerItemsImported > 0)
|
||||
details.push(
|
||||
`${summary.fileManagerItemsImported} file manager items`,
|
||||
);
|
||||
if (summary.dismissedAlertsImported > 0)
|
||||
details.push(`${summary.dismissedAlertsImported} alerts`);
|
||||
if (summary.settingsImported > 0)
|
||||
details.push(`${summary.settingsImported} settings`);
|
||||
|
||||
toast.success(
|
||||
`Import completed: ${imported} items imported${details.length > 0 ? ` (${details.join(', ')})` : ''}, ${skipped} items skipped`
|
||||
`Import completed: ${imported} items imported${details.length > 0 ? ` (${details.join(", ")})` : ""}, ${skipped} items skipped`,
|
||||
);
|
||||
setImportFile(null);
|
||||
setImportPassword("");
|
||||
|
||||
// Refresh the page to show imported data
|
||||
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 1500);
|
||||
@@ -412,7 +400,6 @@ export function AdminSettings({
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const topMarginPx = isTopbarOpen ? 74 : 26;
|
||||
const leftMarginPx = sidebarState === "collapsed" ? 26 : 8;
|
||||
const bottomMarginPx = 8;
|
||||
@@ -856,18 +843,20 @@ export function AdminSettings({
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{/* Simple status display - read only */}
|
||||
<div className="p-4 border rounded bg-card">
|
||||
<div className="flex items-center gap-2">
|
||||
<Lock className="h-4 w-4 text-green-500" />
|
||||
<div>
|
||||
<div className="text-sm font-medium">{t("admin.encryptionStatus")}</div>
|
||||
<div className="text-xs text-green-500">{t("admin.encryptionEnabled")}</div>
|
||||
<div className="text-sm font-medium">
|
||||
{t("admin.encryptionStatus")}
|
||||
</div>
|
||||
<div className="text-xs text-green-500">
|
||||
{t("admin.encryptionEnabled")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Data management functions - export/import */}
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<div className="p-4 border rounded bg-card">
|
||||
<div className="space-y-3">
|
||||
@@ -887,7 +876,7 @@ export function AdminSettings({
|
||||
onChange={(e) => setExportPassword(e.target.value)}
|
||||
placeholder="Enter your password"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
if (e.key === "Enter") {
|
||||
handleExportDatabase();
|
||||
}
|
||||
}}
|
||||
@@ -903,8 +892,7 @@ export function AdminSettings({
|
||||
? t("admin.exporting")
|
||||
: showPasswordInput
|
||||
? t("admin.confirmExport")
|
||||
: t("admin.export")
|
||||
}
|
||||
: t("admin.export")}
|
||||
</Button>
|
||||
{showPasswordInput && (
|
||||
<Button
|
||||
@@ -935,7 +923,9 @@ export function AdminSettings({
|
||||
id="import-file-upload"
|
||||
type="file"
|
||||
accept=".sqlite,.db"
|
||||
onChange={(e) => setImportFile(e.target.files?.[0] || null)}
|
||||
onChange={(e) =>
|
||||
setImportFile(e.target.files?.[0] || null)
|
||||
}
|
||||
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
|
||||
/>
|
||||
<Button
|
||||
@@ -945,7 +935,10 @@ export function AdminSettings({
|
||||
>
|
||||
<span
|
||||
className="truncate"
|
||||
title={importFile?.name || t("admin.pleaseSelectImportFile")}
|
||||
title={
|
||||
importFile?.name ||
|
||||
t("admin.pleaseSelectImportFile")
|
||||
}
|
||||
>
|
||||
{importFile
|
||||
? importFile.name
|
||||
@@ -962,7 +955,7 @@ export function AdminSettings({
|
||||
onChange={(e) => setImportPassword(e.target.value)}
|
||||
placeholder="Enter your password"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
if (e.key === "Enter") {
|
||||
handleImportDatabase();
|
||||
}
|
||||
}}
|
||||
@@ -971,10 +964,14 @@ export function AdminSettings({
|
||||
)}
|
||||
<Button
|
||||
onClick={handleImportDatabase}
|
||||
disabled={importLoading || !importFile || !importPassword.trim()}
|
||||
disabled={
|
||||
importLoading || !importFile || !importPassword.trim()
|
||||
}
|
||||
className="w-full"
|
||||
>
|
||||
{importLoading ? t("admin.importing") : t("admin.import")}
|
||||
{importLoading
|
||||
? t("admin.importing")
|
||||
: t("admin.import")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -222,7 +222,6 @@ export function CredentialEditor({
|
||||
}
|
||||
}, [editingCredential?.id, fullCredentialDetails, form]);
|
||||
|
||||
// Cleanup timeout on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (keyDetectionTimeoutRef.current) {
|
||||
@@ -234,7 +233,6 @@ export function CredentialEditor({
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Detect key type function
|
||||
const handleKeyTypeDetection = async (
|
||||
keyValue: string,
|
||||
keyPassword?: string,
|
||||
@@ -251,7 +249,6 @@ export function CredentialEditor({
|
||||
setDetectedKeyType(result.keyType);
|
||||
} else {
|
||||
setDetectedKeyType("invalid");
|
||||
console.warn("Key detection failed:", result.error);
|
||||
}
|
||||
} catch (error) {
|
||||
setDetectedKeyType("error");
|
||||
@@ -261,7 +258,6 @@ export function CredentialEditor({
|
||||
}
|
||||
};
|
||||
|
||||
// Debounced key type detection
|
||||
const debouncedKeyDetection = (keyValue: string, keyPassword?: string) => {
|
||||
if (keyDetectionTimeoutRef.current) {
|
||||
clearTimeout(keyDetectionTimeoutRef.current);
|
||||
@@ -271,7 +267,6 @@ export function CredentialEditor({
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
// Detect public key type function
|
||||
const handlePublicKeyTypeDetection = async (publicKeyValue: string) => {
|
||||
if (!publicKeyValue || publicKeyValue.trim() === "") {
|
||||
setDetectedPublicKeyType(null);
|
||||
@@ -295,7 +290,6 @@ export function CredentialEditor({
|
||||
}
|
||||
};
|
||||
|
||||
// Debounced public key type detection
|
||||
const debouncedPublicKeyDetection = (publicKeyValue: string) => {
|
||||
if (publicKeyDetectionTimeoutRef.current) {
|
||||
clearTimeout(publicKeyDetectionTimeoutRef.current);
|
||||
@@ -718,7 +712,6 @@ export function CredentialEditor({
|
||||
if (result.success) {
|
||||
form.setValue("key", result.privateKey);
|
||||
form.setValue("publicKey", result.publicKey);
|
||||
// Auto-fill the key password field if passphrase was used
|
||||
if (keyGenerationPassphrase) {
|
||||
form.setValue(
|
||||
"keyPassword",
|
||||
@@ -770,7 +763,6 @@ export function CredentialEditor({
|
||||
if (result.success) {
|
||||
form.setValue("key", result.privateKey);
|
||||
form.setValue("publicKey", result.publicKey);
|
||||
// Auto-fill the key password field if passphrase was used
|
||||
if (keyGenerationPassphrase) {
|
||||
form.setValue(
|
||||
"keyPassword",
|
||||
@@ -822,7 +814,6 @@ export function CredentialEditor({
|
||||
if (result.success) {
|
||||
form.setValue("key", result.privateKey);
|
||||
form.setValue("publicKey", result.publicKey);
|
||||
// Auto-fill the key password field if passphrase was used
|
||||
if (keyGenerationPassphrase) {
|
||||
form.setValue(
|
||||
"keyPassword",
|
||||
@@ -924,7 +915,9 @@ export function CredentialEditor({
|
||||
form.watch("keyPassword"),
|
||||
);
|
||||
}}
|
||||
placeholder={t("placeholders.pastePrivateKey")}
|
||||
placeholder={t(
|
||||
"placeholders.pastePrivateKey",
|
||||
)}
|
||||
theme={oneDark}
|
||||
className="border border-input rounded-md"
|
||||
minHeight="120px"
|
||||
@@ -1044,9 +1037,7 @@ export function CredentialEditor({
|
||||
);
|
||||
|
||||
if (result.success && result.publicKey) {
|
||||
// Set the generated public key
|
||||
field.onChange(result.publicKey);
|
||||
// Trigger public key detection
|
||||
debouncedPublicKeyDetection(
|
||||
result.publicKey,
|
||||
);
|
||||
|
||||
@@ -218,7 +218,6 @@ const CredentialViewer: React.FC<CredentialViewerProps> = ({
|
||||
</SheetHeader>
|
||||
|
||||
<div className="space-y-10">
|
||||
{/* Tab Navigation */}
|
||||
<div className="flex space-x-2 p-2 bg-zinc-100 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-lg">
|
||||
<Button
|
||||
variant={activeTab === "overview" ? "default" : "ghost"}
|
||||
@@ -249,7 +248,6 @@ const CredentialViewer: React.FC<CredentialViewerProps> = ({
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
{activeTab === "overview" && (
|
||||
<div className="grid gap-10 lg:grid-cols-2">
|
||||
<Card className="border-zinc-200 dark:border-zinc-700">
|
||||
|
||||
@@ -804,7 +804,6 @@ export function CredentialsManager({
|
||||
</SheetHeader>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Credential Information Card */}
|
||||
{deployingCredential && (
|
||||
<div className="border border-zinc-200 dark:border-zinc-700 rounded-lg p-4 bg-zinc-50 dark:bg-zinc-900/50">
|
||||
<h4 className="text-sm font-semibold text-zinc-800 dark:text-zinc-200 mb-3 flex items-center">
|
||||
@@ -856,7 +855,6 @@ export function CredentialsManager({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Target Host Selection */}
|
||||
<div className="space-y-3">
|
||||
<label className="text-sm font-semibold text-zinc-800 dark:text-zinc-200 flex items-center">
|
||||
<Server className="h-4 w-4 mr-2 text-zinc-500" />
|
||||
@@ -888,7 +886,6 @@ export function CredentialsManager({
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Information Note */}
|
||||
<div className="border border-blue-200 dark:border-blue-800 rounded-lg p-4 bg-blue-50 dark:bg-blue-900/20">
|
||||
<div className="flex items-start space-x-3">
|
||||
<Info className="h-4 w-4 text-blue-600 dark:text-blue-400 mt-0.5 flex-shrink-0" />
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -107,7 +107,6 @@ export function FileManagerContextMenu({
|
||||
useEffect(() => {
|
||||
if (!isVisible) return;
|
||||
|
||||
// Adjust menu position to avoid going off screen
|
||||
const adjustPosition = () => {
|
||||
const menuWidth = 200;
|
||||
const menuHeight = 300;
|
||||
@@ -130,13 +129,10 @@ export function FileManagerContextMenu({
|
||||
|
||||
adjustPosition();
|
||||
|
||||
// Delay adding event listeners to avoid capturing the click that triggered the menu
|
||||
let cleanupFn: (() => void) | null = null;
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
// Click outside to close menu
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
// Check if click is inside menu
|
||||
const target = event.target as Element;
|
||||
const menuElement = document.querySelector("[data-context-menu]");
|
||||
|
||||
@@ -145,13 +141,11 @@ export function FileManagerContextMenu({
|
||||
}
|
||||
};
|
||||
|
||||
// Right-click to close menu (Windows behavior)
|
||||
const handleRightClick = (event: MouseEvent) => {
|
||||
event.preventDefault();
|
||||
onClose();
|
||||
};
|
||||
|
||||
// Keyboard support
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
@@ -159,12 +153,10 @@ export function FileManagerContextMenu({
|
||||
}
|
||||
};
|
||||
|
||||
// Close menu on window blur
|
||||
const handleBlur = () => {
|
||||
onClose();
|
||||
};
|
||||
|
||||
// Close menu on scroll (Windows behavior)
|
||||
const handleScroll = () => {
|
||||
onClose();
|
||||
};
|
||||
@@ -175,7 +167,6 @@ export function FileManagerContextMenu({
|
||||
window.addEventListener("blur", handleBlur);
|
||||
window.addEventListener("scroll", handleScroll, true);
|
||||
|
||||
// Set cleanup function
|
||||
cleanupFn = () => {
|
||||
document.removeEventListener("mousedown", handleClickOutside, true);
|
||||
document.removeEventListener("contextmenu", handleRightClick);
|
||||
@@ -183,7 +174,7 @@ export function FileManagerContextMenu({
|
||||
window.removeEventListener("blur", handleBlur);
|
||||
window.removeEventListener("scroll", handleScroll, true);
|
||||
};
|
||||
}, 50); // 50ms delay to ensure we don't capture the click that triggered the menu
|
||||
}, 50);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeoutId);
|
||||
@@ -204,13 +195,9 @@ export function FileManagerContextMenu({
|
||||
(f) => f.type === "file" && f.executable,
|
||||
);
|
||||
|
||||
// Build menu items
|
||||
const menuItems: MenuItem[] = [];
|
||||
|
||||
if (isFileContext) {
|
||||
// Menu when files/folders are selected
|
||||
|
||||
// Open terminal function - supports files and folders
|
||||
if (onOpenTerminal) {
|
||||
const targetPath = isSingleFile
|
||||
? files[0].type === "directory"
|
||||
@@ -229,7 +216,6 @@ export function FileManagerContextMenu({
|
||||
});
|
||||
}
|
||||
|
||||
// Run executable file function - only show for single executable files
|
||||
if (isSingleFile && hasExecutableFiles && onRunExecutable) {
|
||||
menuItems.push({
|
||||
icon: <Play className="w-4 h-4" />,
|
||||
@@ -239,7 +225,6 @@ export function FileManagerContextMenu({
|
||||
});
|
||||
}
|
||||
|
||||
// Add separator (if above functions exist)
|
||||
if (
|
||||
onOpenTerminal ||
|
||||
(isSingleFile && hasExecutableFiles && onRunExecutable)
|
||||
@@ -247,7 +232,6 @@ export function FileManagerContextMenu({
|
||||
menuItems.push({ separator: true } as MenuItem);
|
||||
}
|
||||
|
||||
// Preview function
|
||||
if (hasFiles && onPreview) {
|
||||
menuItems.push({
|
||||
icon: <Eye className="w-4 h-4" />,
|
||||
@@ -257,7 +241,6 @@ export function FileManagerContextMenu({
|
||||
});
|
||||
}
|
||||
|
||||
// Download function - use proper download handler
|
||||
if (hasFiles && onDownload) {
|
||||
menuItems.push({
|
||||
icon: <Download className="w-4 h-4" />,
|
||||
@@ -269,7 +252,6 @@ export function FileManagerContextMenu({
|
||||
});
|
||||
}
|
||||
|
||||
// PIN/UNPIN function - only show for single files
|
||||
if (isSingleFile && files[0].type === "file") {
|
||||
const isCurrentlyPinned = isPinned ? isPinned(files[0]) : false;
|
||||
|
||||
@@ -288,7 +270,6 @@ export function FileManagerContextMenu({
|
||||
}
|
||||
}
|
||||
|
||||
// Add folder shortcut - only show for single folders
|
||||
if (isSingleFile && files[0].type === "directory" && onAddShortcut) {
|
||||
menuItems.push({
|
||||
icon: <Bookmark className="w-4 h-4" />,
|
||||
@@ -297,7 +278,6 @@ export function FileManagerContextMenu({
|
||||
});
|
||||
}
|
||||
|
||||
// Add separator (if above functions exist)
|
||||
if (
|
||||
(hasFiles && (onPreview || onDragToDesktop)) ||
|
||||
(isSingleFile &&
|
||||
@@ -308,7 +288,6 @@ export function FileManagerContextMenu({
|
||||
menuItems.push({ separator: true } as MenuItem);
|
||||
}
|
||||
|
||||
// Rename function
|
||||
if (isSingleFile && onRename) {
|
||||
menuItems.push({
|
||||
icon: <Edit3 className="w-4 h-4" />,
|
||||
@@ -318,7 +297,6 @@ export function FileManagerContextMenu({
|
||||
});
|
||||
}
|
||||
|
||||
// Copy function
|
||||
if (onCopy) {
|
||||
menuItems.push({
|
||||
icon: <Copy className="w-4 h-4" />,
|
||||
@@ -330,7 +308,6 @@ export function FileManagerContextMenu({
|
||||
});
|
||||
}
|
||||
|
||||
// Cut function
|
||||
if (onCut) {
|
||||
menuItems.push({
|
||||
icon: <Scissors className="w-4 h-4" />,
|
||||
@@ -342,12 +319,10 @@ export function FileManagerContextMenu({
|
||||
});
|
||||
}
|
||||
|
||||
// Add separator (if edit functions exist)
|
||||
if ((isSingleFile && onRename) || onCopy || onCut) {
|
||||
menuItems.push({ separator: true } as MenuItem);
|
||||
}
|
||||
|
||||
// Delete function
|
||||
if (onDelete) {
|
||||
menuItems.push({
|
||||
icon: <Trash2 className="w-4 h-4" />,
|
||||
@@ -360,12 +335,10 @@ export function FileManagerContextMenu({
|
||||
});
|
||||
}
|
||||
|
||||
// Add separator (if delete function exists)
|
||||
if (onDelete) {
|
||||
menuItems.push({ separator: true } as MenuItem);
|
||||
}
|
||||
|
||||
// Properties function
|
||||
if (isSingleFile && onProperties) {
|
||||
menuItems.push({
|
||||
icon: <Info className="w-4 h-4" />,
|
||||
@@ -374,9 +347,6 @@ export function FileManagerContextMenu({
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Empty area right-click menu
|
||||
|
||||
// Open terminal in current directory
|
||||
if (onOpenTerminal && currentPath) {
|
||||
menuItems.push({
|
||||
icon: <Terminal className="w-4 h-4" />,
|
||||
@@ -386,7 +356,6 @@ export function FileManagerContextMenu({
|
||||
});
|
||||
}
|
||||
|
||||
// Upload function
|
||||
if (onUpload) {
|
||||
menuItems.push({
|
||||
icon: <Upload className="w-4 h-4" />,
|
||||
@@ -396,12 +365,10 @@ export function FileManagerContextMenu({
|
||||
});
|
||||
}
|
||||
|
||||
// Add separator (if terminal or upload functions exist)
|
||||
if ((onOpenTerminal && currentPath) || onUpload) {
|
||||
menuItems.push({ separator: true } as MenuItem);
|
||||
}
|
||||
|
||||
// New folder
|
||||
if (onNewFolder) {
|
||||
menuItems.push({
|
||||
icon: <FolderPlus className="w-4 h-4" />,
|
||||
@@ -411,7 +378,6 @@ export function FileManagerContextMenu({
|
||||
});
|
||||
}
|
||||
|
||||
// New file
|
||||
if (onNewFile) {
|
||||
menuItems.push({
|
||||
icon: <FilePlus className="w-4 h-4" />,
|
||||
@@ -421,12 +387,10 @@ export function FileManagerContextMenu({
|
||||
});
|
||||
}
|
||||
|
||||
// Add separator (if new functions exist)
|
||||
if (onNewFolder || onNewFile) {
|
||||
menuItems.push({ separator: true } as MenuItem);
|
||||
}
|
||||
|
||||
// Refresh function
|
||||
if (onRefresh) {
|
||||
menuItems.push({
|
||||
icon: <RefreshCw className="w-4 h-4" />,
|
||||
@@ -436,7 +400,6 @@ export function FileManagerContextMenu({
|
||||
});
|
||||
}
|
||||
|
||||
// Paste function
|
||||
if (hasClipboard && onPaste) {
|
||||
menuItems.push({
|
||||
icon: <Clipboard className="w-4 h-4" />,
|
||||
@@ -447,15 +410,12 @@ export function FileManagerContextMenu({
|
||||
}
|
||||
}
|
||||
|
||||
// Filter out consecutive separators
|
||||
const filteredMenuItems = menuItems.filter((item, index) => {
|
||||
if (!item.separator) return true;
|
||||
|
||||
// If it's a separator, check if previous and next are also separators
|
||||
const prevItem = index > 0 ? menuItems[index - 1] : null;
|
||||
const nextItem = index < menuItems.length - 1 ? menuItems[index + 1] : null;
|
||||
|
||||
// If previous or next is a separator, filter out current separator
|
||||
if (prevItem?.separator || nextItem?.separator) {
|
||||
return false;
|
||||
}
|
||||
@@ -463,7 +423,6 @@ export function FileManagerContextMenu({
|
||||
return true;
|
||||
});
|
||||
|
||||
// Remove separators at beginning and end
|
||||
const finalMenuItems = filteredMenuItems.filter((item, index) => {
|
||||
if (!item.separator) return true;
|
||||
return index > 0 && index < filteredMenuItems.length - 1;
|
||||
@@ -471,10 +430,8 @@ export function FileManagerContextMenu({
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Transparent overlay to capture click events */}
|
||||
<div className="fixed inset-0 z-[99990]" />
|
||||
|
||||
{/* Menu body */}
|
||||
<div
|
||||
data-context-menu
|
||||
className="fixed bg-dark-bg border border-dark-border rounded-lg shadow-xl min-w-[180px] max-w-[250px] z-[99995] overflow-hidden"
|
||||
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
Upload,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
MoreHorizontal,
|
||||
RefreshCw,
|
||||
ArrowUp,
|
||||
FileSymlink,
|
||||
@@ -26,20 +25,16 @@ import {
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { FileItem } from "../../../types/index.js";
|
||||
|
||||
// Linus-style data structure: separate creation intent from actual files
|
||||
interface CreateIntent {
|
||||
id: string;
|
||||
type: 'file' | 'directory';
|
||||
type: "file" | "directory";
|
||||
defaultName: string;
|
||||
currentName: string;
|
||||
}
|
||||
|
||||
// Format file size
|
||||
function formatFileSize(bytes?: number): string {
|
||||
// Handle undefined or null cases
|
||||
if (bytes === undefined || bytes === null) return "-";
|
||||
|
||||
// Display 0-byte files as "0 B"
|
||||
if (bytes === 0) return "0 B";
|
||||
|
||||
const units = ["B", "KB", "MB", "GB", "TB"];
|
||||
@@ -51,7 +46,6 @@ function formatFileSize(bytes?: number): string {
|
||||
unitIndex++;
|
||||
}
|
||||
|
||||
// Display one decimal place for values less than 10, integers for values greater than 10
|
||||
const formattedSize =
|
||||
size < 10 && unitIndex > 0 ? size.toFixed(1) : Math.round(size).toString();
|
||||
|
||||
@@ -95,7 +89,6 @@ interface FileManagerGridProps {
|
||||
onSystemDragStart?: (files: FileItem[]) => void;
|
||||
onSystemDragEnd?: (e: DragEvent, files: FileItem[]) => void;
|
||||
hasClipboard?: boolean;
|
||||
// Linus-style creation intent props
|
||||
createIntent?: CreateIntent | null;
|
||||
onConfirmCreate?: (name: string) => void;
|
||||
onCancelCreate?: () => void;
|
||||
@@ -206,16 +199,12 @@ export function FileManagerGrid({
|
||||
const gridRef = useRef<HTMLDivElement>(null);
|
||||
const [editingName, setEditingName] = useState("");
|
||||
|
||||
|
||||
|
||||
// Unified drag state management
|
||||
const [dragState, setDragState] = useState<DragState>({
|
||||
type: "none",
|
||||
files: [],
|
||||
counter: 0,
|
||||
});
|
||||
|
||||
// Global mouse move listener - for drag tooltip following
|
||||
useEffect(() => {
|
||||
const handleGlobalMouseMove = (e: MouseEvent) => {
|
||||
if (dragState.type === "internal" && dragState.files.length > 0) {
|
||||
@@ -235,11 +224,9 @@ export function FileManagerGrid({
|
||||
|
||||
const editInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Set initial name when starting edit
|
||||
useEffect(() => {
|
||||
if (editingFile) {
|
||||
setEditingName(editingFile.name);
|
||||
// Delay focus to ensure DOM is updated
|
||||
setTimeout(() => {
|
||||
editInputRef.current?.focus();
|
||||
editInputRef.current?.select();
|
||||
@@ -247,7 +234,6 @@ export function FileManagerGrid({
|
||||
}
|
||||
}, [editingFile]);
|
||||
|
||||
// Handle edit confirmation
|
||||
const handleEditConfirm = () => {
|
||||
if (
|
||||
editingFile &&
|
||||
@@ -260,13 +246,11 @@ export function FileManagerGrid({
|
||||
onCancelEdit?.();
|
||||
};
|
||||
|
||||
// Handle edit cancellation
|
||||
const handleEditCancel = () => {
|
||||
setEditingName("");
|
||||
onCancelEdit?.();
|
||||
};
|
||||
|
||||
// Handle input key events
|
||||
const handleEditKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
@@ -277,9 +261,7 @@ export function FileManagerGrid({
|
||||
}
|
||||
};
|
||||
|
||||
// File drag handling function
|
||||
const handleFileDragStart = (e: React.DragEvent, file: FileItem) => {
|
||||
// If dragged file is selected, drag all selected files
|
||||
const filesToDrag = selectedFiles.includes(file) ? selectedFiles : [file];
|
||||
|
||||
setDragState({
|
||||
@@ -290,7 +272,6 @@ export function FileManagerGrid({
|
||||
mousePosition: { x: e.clientX, y: e.clientY },
|
||||
});
|
||||
|
||||
// Set drag data, add internal drag identifier
|
||||
const dragData = {
|
||||
type: "internal_files",
|
||||
files: filesToDrag.map((f) => f.path),
|
||||
@@ -303,7 +284,6 @@ export function FileManagerGrid({
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
// Only set target when dragging to different file and not being dragged file
|
||||
if (
|
||||
dragState.type === "internal" &&
|
||||
!dragState.files.some((f) => f.path === targetFile.path)
|
||||
@@ -317,7 +297,6 @@ export function FileManagerGrid({
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
// Clear drag target highlight
|
||||
if (dragState.target?.path === targetFile.path) {
|
||||
setDragState((prev) => ({ ...prev, target: undefined }));
|
||||
}
|
||||
@@ -332,46 +311,23 @@ export function FileManagerGrid({
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if dragging to self
|
||||
const isDroppingOnSelf = dragState.files.some(
|
||||
(f) => f.path === targetFile.path,
|
||||
);
|
||||
if (isDroppingOnSelf) {
|
||||
console.log("Ignoring drop on self");
|
||||
setDragState({ type: "none", files: [], counter: 0 });
|
||||
return;
|
||||
}
|
||||
|
||||
// Determine drag behavior:
|
||||
// 1. File/folder drag to folder = move operation
|
||||
// 2. Single file drag to single file = diff comparison
|
||||
// 3. Other cases = invalid operation
|
||||
|
||||
if (targetFile.type === "directory") {
|
||||
// Move operation
|
||||
console.log(
|
||||
"Moving files to directory:",
|
||||
dragState.files.map((f) => f.name),
|
||||
"to",
|
||||
targetFile.name,
|
||||
);
|
||||
onFileDrop?.(dragState.files, targetFile);
|
||||
} else if (
|
||||
targetFile.type === "file" &&
|
||||
dragState.files.length === 1 &&
|
||||
dragState.files[0].type === "file"
|
||||
) {
|
||||
// Diff comparison operation
|
||||
console.log(
|
||||
"Comparing files:",
|
||||
dragState.files[0].name,
|
||||
"vs",
|
||||
targetFile.name,
|
||||
);
|
||||
onFileDiff?.(dragState.files[0], targetFile);
|
||||
} else {
|
||||
// Invalid operation, notify user
|
||||
console.log("Invalid drag operation");
|
||||
}
|
||||
|
||||
setDragState({ type: "none", files: [], counter: 0 });
|
||||
@@ -381,7 +337,6 @@ export function FileManagerGrid({
|
||||
const draggedFiles = dragState.draggedFiles || [];
|
||||
setDragState({ type: "none", files: [], counter: 0 });
|
||||
|
||||
// Trigger system-level drag end detection with dragged files
|
||||
onSystemDragEnd?.(e.nativeEvent, draggedFiles);
|
||||
};
|
||||
|
||||
@@ -398,17 +353,14 @@ export function FileManagerGrid({
|
||||
} | null>(null);
|
||||
const [justFinishedSelecting, setJustFinishedSelecting] = useState(false);
|
||||
|
||||
// Navigation history management
|
||||
const [navigationHistory, setNavigationHistory] = useState<string[]>([
|
||||
currentPath,
|
||||
]);
|
||||
const [historyIndex, setHistoryIndex] = useState(0);
|
||||
|
||||
// Path editing state
|
||||
const [isEditingPath, setIsEditingPath] = useState(false);
|
||||
const [editPathValue, setEditPathValue] = useState(currentPath);
|
||||
|
||||
// Update navigation history
|
||||
useEffect(() => {
|
||||
const lastPath = navigationHistory[historyIndex];
|
||||
if (currentPath !== lastPath) {
|
||||
@@ -419,7 +371,6 @@ export function FileManagerGrid({
|
||||
}
|
||||
}, [currentPath]);
|
||||
|
||||
// Navigation functions
|
||||
const goBack = () => {
|
||||
if (historyIndex > 0) {
|
||||
const newIndex = historyIndex - 1;
|
||||
@@ -447,7 +398,6 @@ export function FileManagerGrid({
|
||||
}
|
||||
};
|
||||
|
||||
// Path navigation
|
||||
const pathParts = currentPath.split("/").filter(Boolean);
|
||||
const navigateToPath = (index: number) => {
|
||||
if (index === -1) {
|
||||
@@ -458,7 +408,6 @@ export function FileManagerGrid({
|
||||
}
|
||||
};
|
||||
|
||||
// Path editing functionality
|
||||
const startEditingPath = () => {
|
||||
setEditPathValue(currentPath);
|
||||
setIsEditingPath(true);
|
||||
@@ -472,7 +421,6 @@ export function FileManagerGrid({
|
||||
const confirmEditingPath = () => {
|
||||
const trimmedPath = editPathValue.trim();
|
||||
if (trimmedPath) {
|
||||
// Ensure path starts with /
|
||||
const normalizedPath = trimmedPath.startsWith("/")
|
||||
? trimmedPath
|
||||
: "/" + trimmedPath;
|
||||
@@ -491,31 +439,26 @@ export function FileManagerGrid({
|
||||
}
|
||||
};
|
||||
|
||||
// Sync editPathValue with currentPath
|
||||
useEffect(() => {
|
||||
if (!isEditingPath) {
|
||||
setEditPathValue(currentPath);
|
||||
}
|
||||
}, [currentPath, isEditingPath]);
|
||||
|
||||
// Drag and drop handling - distinguish internal file drag and external file upload
|
||||
const handleDragEnter = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
// Check if it's internal file drag
|
||||
const isInternalDrag = dragState.type === "internal";
|
||||
|
||||
if (!isInternalDrag) {
|
||||
// Only show upload prompt for external file drag
|
||||
setDragState((prev) => ({
|
||||
...prev,
|
||||
type: "external",
|
||||
counter: prev.counter + 1,
|
||||
}));
|
||||
if (e.dataTransfer.items && e.dataTransfer.items.length > 0) {
|
||||
// External drag detected
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -527,7 +470,6 @@ export function FileManagerGrid({
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
// Check if it's internal file drag
|
||||
const isInternalDrag = dragState.type === "internal";
|
||||
|
||||
if (!isInternalDrag && dragState.type === "external") {
|
||||
@@ -549,11 +491,9 @@ export function FileManagerGrid({
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
// Check if it's internal file drag
|
||||
const isInternalDrag = dragState.type === "internal";
|
||||
|
||||
if (isInternalDrag) {
|
||||
// Update mouse position
|
||||
setDragState((prev) => ({
|
||||
...prev,
|
||||
mousePosition: { x: e.clientX, y: e.clientY },
|
||||
@@ -566,15 +506,11 @@ export function FileManagerGrid({
|
||||
[dragState.type],
|
||||
);
|
||||
|
||||
// Mouse wheel event handling, ensure scrolling works normally
|
||||
const handleWheel = useCallback((e: React.WheelEvent) => {
|
||||
// Don't prevent default scroll behavior, let browser handle scrolling
|
||||
e.stopPropagation();
|
||||
}, []);
|
||||
|
||||
// Box selection functionality implementation
|
||||
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
||||
// Only start box selection in empty area, avoid interfering with file clicks
|
||||
if (e.target === e.currentTarget && e.button === 0) {
|
||||
e.preventDefault();
|
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
@@ -585,7 +521,6 @@ export function FileManagerGrid({
|
||||
setSelectionStart({ x: startX, y: startY });
|
||||
setSelectionRect({ x: startX, y: startY, width: 0, height: 0 });
|
||||
|
||||
// Reset flag for just completed selection, prepare for new selection
|
||||
setJustFinishedSelecting(false);
|
||||
}
|
||||
}, []);
|
||||
@@ -604,7 +539,6 @@ export function FileManagerGrid({
|
||||
|
||||
setSelectionRect({ x, y, width, height });
|
||||
|
||||
// Detect intersection with file items, perform real-time selection
|
||||
if (gridRef.current) {
|
||||
const fileElements =
|
||||
gridRef.current.querySelectorAll("[data-file-path]");
|
||||
@@ -614,7 +548,6 @@ export function FileManagerGrid({
|
||||
const elementRect = element.getBoundingClientRect();
|
||||
const containerRect = gridRef.current!.getBoundingClientRect();
|
||||
|
||||
// Simplify coordinate calculation - directly use coordinates relative to container
|
||||
const relativeElementRect = {
|
||||
left: elementRect.left - containerRect.left,
|
||||
top: elementRect.top - containerRect.top,
|
||||
@@ -622,7 +555,6 @@ export function FileManagerGrid({
|
||||
bottom: elementRect.bottom - containerRect.top,
|
||||
};
|
||||
|
||||
// Selection box coordinates
|
||||
const selectionBox = {
|
||||
left: x,
|
||||
top: y,
|
||||
@@ -630,7 +562,6 @@ export function FileManagerGrid({
|
||||
bottom: y + height,
|
||||
};
|
||||
|
||||
// Check if intersecting
|
||||
const intersects = !(
|
||||
relativeElementRect.right < selectionBox.left ||
|
||||
relativeElementRect.left > selectionBox.right ||
|
||||
@@ -642,21 +573,13 @@ export function FileManagerGrid({
|
||||
const filePath = element.getAttribute("data-file-path");
|
||||
if (filePath) {
|
||||
selectedPaths.push(filePath);
|
||||
console.log("Selected file:", filePath);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
console.log("Total selected paths:", selectedPaths.length);
|
||||
|
||||
// Update selected files
|
||||
const newSelection = files.filter((file) =>
|
||||
selectedPaths.includes(file.path),
|
||||
);
|
||||
console.log(
|
||||
"New selection:",
|
||||
newSelection.map((f) => f.name),
|
||||
);
|
||||
onSelectionChange(newSelection);
|
||||
}
|
||||
}
|
||||
@@ -671,7 +594,6 @@ export function FileManagerGrid({
|
||||
setSelectionStart(null);
|
||||
setSelectionRect(null);
|
||||
|
||||
// Only consider as box selection when movement distance is large enough, otherwise it's a click
|
||||
const startPos = selectionStart;
|
||||
if (startPos) {
|
||||
const rect = gridRef.current?.getBoundingClientRect();
|
||||
@@ -683,13 +605,11 @@ export function FileManagerGrid({
|
||||
);
|
||||
|
||||
if (distance > 5) {
|
||||
// Real box selection, set flag to prevent immediate clearing
|
||||
setJustFinishedSelecting(true);
|
||||
setTimeout(() => {
|
||||
setJustFinishedSelecting(false);
|
||||
}, 50);
|
||||
} else {
|
||||
// Just a click, don't set flag, let handleGridClick handle normally
|
||||
setJustFinishedSelecting(false);
|
||||
}
|
||||
}
|
||||
@@ -699,7 +619,6 @@ export function FileManagerGrid({
|
||||
[isSelecting, selectionStart],
|
||||
);
|
||||
|
||||
// Global mouse event listener, ensure box selection can end outside container
|
||||
useEffect(() => {
|
||||
const handleGlobalMouseUp = (e: MouseEvent) => {
|
||||
if (isSelecting) {
|
||||
@@ -707,7 +626,6 @@ export function FileManagerGrid({
|
||||
setSelectionStart(null);
|
||||
setSelectionRect(null);
|
||||
|
||||
// Global mouseup indicates drag box selection, set flag
|
||||
setJustFinishedSelecting(true);
|
||||
setTimeout(() => {
|
||||
setJustFinishedSelecting(false);
|
||||
@@ -747,58 +665,32 @@ export function FileManagerGrid({
|
||||
e.stopPropagation();
|
||||
|
||||
if (dragState.type === "internal") {
|
||||
// Internal drag to empty area: just cancel the drag operation
|
||||
console.log("Internal drag to empty area - cancelling drag operation");
|
||||
// Do not trigger download here - system drag end will handle it if truly outside window
|
||||
setDragState({ type: "none", files: [], counter: 0 });
|
||||
} else if (dragState.type === "external") {
|
||||
// External drag: handle file upload
|
||||
if (onUpload && e.dataTransfer.files.length > 0) {
|
||||
onUpload(e.dataTransfer.files);
|
||||
}
|
||||
}
|
||||
|
||||
// Reset drag state
|
||||
setDragState({ type: "none", files: [], counter: 0 });
|
||||
},
|
||||
[onUpload, onDownload, dragState],
|
||||
);
|
||||
|
||||
// File selection handling
|
||||
const handleFileClick = (file: FileItem, event: React.MouseEvent) => {
|
||||
event.stopPropagation();
|
||||
|
||||
// Ensure grid gets focus to support keyboard events
|
||||
if (gridRef.current) {
|
||||
gridRef.current.focus();
|
||||
}
|
||||
|
||||
console.log(
|
||||
"File clicked:",
|
||||
file.name,
|
||||
"Current selected:",
|
||||
selectedFiles.length,
|
||||
);
|
||||
|
||||
if (event.detail === 2) {
|
||||
// Double click to open
|
||||
console.log("Double click - opening file");
|
||||
onFileOpen(file);
|
||||
} else {
|
||||
// Single click to select
|
||||
const multiSelect = event.ctrlKey || event.metaKey;
|
||||
const rangeSelect = event.shiftKey;
|
||||
|
||||
console.log(
|
||||
"Single click - multiSelect:",
|
||||
multiSelect,
|
||||
"rangeSelect:",
|
||||
rangeSelect,
|
||||
);
|
||||
|
||||
if (rangeSelect && selectedFiles.length > 0) {
|
||||
// Range selection (Shift+click)
|
||||
console.log("Range selection");
|
||||
const lastSelected = selectedFiles[selectedFiles.length - 1];
|
||||
const currentIndex = files.findIndex((f) => f.path === file.path);
|
||||
const lastIndex = files.findIndex((f) => f.path === lastSelected.path);
|
||||
@@ -807,36 +699,26 @@ export function FileManagerGrid({
|
||||
const start = Math.min(currentIndex, lastIndex);
|
||||
const end = Math.max(currentIndex, lastIndex);
|
||||
const rangeFiles = files.slice(start, end + 1);
|
||||
console.log("Range selection result:", rangeFiles.length, "files");
|
||||
onSelectionChange(rangeFiles);
|
||||
}
|
||||
} else if (multiSelect) {
|
||||
// Multi-selection (Ctrl+click)
|
||||
console.log("Multi selection");
|
||||
const isSelected = selectedFiles.some((f) => f.path === file.path);
|
||||
if (isSelected) {
|
||||
console.log("Removing from selection");
|
||||
onSelectionChange(selectedFiles.filter((f) => f.path !== file.path));
|
||||
} else {
|
||||
console.log("Adding to selection");
|
||||
onSelectionChange([...selectedFiles, file]);
|
||||
}
|
||||
} else {
|
||||
// Single selection
|
||||
console.log("Single selection - should select only:", file.name);
|
||||
onSelectionChange([file]);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Click empty area to cancel selection
|
||||
const handleGridClick = (event: React.MouseEvent) => {
|
||||
// Ensure grid gets focus to support keyboard events
|
||||
if (gridRef.current) {
|
||||
gridRef.current.focus();
|
||||
}
|
||||
|
||||
// If just completed box selection, don't clear selection
|
||||
if (
|
||||
event.target === event.currentTarget &&
|
||||
!isSelecting &&
|
||||
@@ -846,10 +728,8 @@ export function FileManagerGrid({
|
||||
}
|
||||
};
|
||||
|
||||
// Keyboard support
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
// Check if input box or editable element has focus, skip if so
|
||||
const activeElement = document.activeElement;
|
||||
if (
|
||||
activeElement &&
|
||||
@@ -868,7 +748,6 @@ export function FileManagerGrid({
|
||||
case "A":
|
||||
if (event.ctrlKey || event.metaKey) {
|
||||
event.preventDefault();
|
||||
console.log("Ctrl+A pressed - selecting all files:", files.length);
|
||||
onSelectionChange([...files]);
|
||||
}
|
||||
break;
|
||||
@@ -910,7 +789,6 @@ export function FileManagerGrid({
|
||||
break;
|
||||
case "Delete":
|
||||
if (selectedFiles.length > 0 && onDelete) {
|
||||
// Trigger delete operation
|
||||
onDelete(selectedFiles);
|
||||
}
|
||||
break;
|
||||
@@ -922,7 +800,7 @@ export function FileManagerGrid({
|
||||
break;
|
||||
case "y":
|
||||
case "Y":
|
||||
if ((event.ctrlKey || event.metaKey)) {
|
||||
if (event.ctrlKey || event.metaKey) {
|
||||
event.preventDefault();
|
||||
onRefresh();
|
||||
}
|
||||
@@ -957,9 +835,7 @@ export function FileManagerGrid({
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col bg-dark-bg overflow-hidden">
|
||||
{/* Toolbar and path navigation */}
|
||||
<div className="flex-shrink-0 border-b border-dark-border">
|
||||
{/* Navigation buttons */}
|
||||
<div className="flex items-center gap-1 p-2 border-b border-dark-border">
|
||||
<button
|
||||
onClick={goBack}
|
||||
@@ -1004,10 +880,8 @@ export function FileManagerGrid({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Breadcrumb navigation */}
|
||||
<div className="flex items-center px-3 py-2 text-sm">
|
||||
{isEditingPath ? (
|
||||
// Edit mode: path input box
|
||||
<div className="flex-1 flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
@@ -1038,7 +912,6 @@ export function FileManagerGrid({
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
// View mode: breadcrumb navigation
|
||||
<>
|
||||
<button
|
||||
onClick={() => navigateToPath(-1)}
|
||||
@@ -1071,7 +944,6 @@ export function FileManagerGrid({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main file grid - scroll area */}
|
||||
<div className="flex-1 relative overflow-hidden">
|
||||
<div
|
||||
ref={gridRef}
|
||||
@@ -1092,7 +964,6 @@ export function FileManagerGrid({
|
||||
onContextMenu={(e) => onContextMenu?.(e)}
|
||||
tabIndex={0}
|
||||
>
|
||||
{/* Drag hint overlay */}
|
||||
{dragState.type === "external" && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-background/50 backdrop-blur-sm z-10 pointer-events-none animate-in fade-in-0">
|
||||
<div className="text-center p-8 bg-background/95 border-2 border-dashed border-primary rounded-lg shadow-lg">
|
||||
@@ -1128,7 +999,6 @@ export function FileManagerGrid({
|
||||
</div>
|
||||
) : viewMode === "grid" ? (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 xl:grid-cols-8 gap-4">
|
||||
{/* Linus-style creation intent UI - pure separation */}
|
||||
{createIntent && (
|
||||
<CreateIntentGridItem
|
||||
intent={createIntent}
|
||||
@@ -1169,10 +1039,8 @@ export function FileManagerGrid({
|
||||
onDragEnd={handleFileDragEnd}
|
||||
>
|
||||
<div className="flex flex-col items-center text-center">
|
||||
{/* File icon */}
|
||||
<div className="mb-2">{getFileIcon(file, viewMode)}</div>
|
||||
|
||||
{/* File name */}
|
||||
<div className="w-full flex flex-col items-center">
|
||||
{editingFile?.path === file.path ? (
|
||||
<input
|
||||
@@ -1221,7 +1089,6 @@ export function FileManagerGrid({
|
||||
) : (
|
||||
/* List view */
|
||||
<div className="space-y-1">
|
||||
{/* Linus-style creation intent UI - list view */}
|
||||
{createIntent && (
|
||||
<CreateIntentListItem
|
||||
intent={createIntent}
|
||||
@@ -1260,12 +1127,10 @@ export function FileManagerGrid({
|
||||
onDrop={(e) => handleFileDrop(e, file)}
|
||||
onDragEnd={handleFileDragEnd}
|
||||
>
|
||||
{/* File icon */}
|
||||
<div className="flex-shrink-0">
|
||||
{getFileIcon(file, viewMode)}
|
||||
</div>
|
||||
|
||||
{/* File info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
{editingFile?.path === file.path ? (
|
||||
<input
|
||||
@@ -1305,7 +1170,6 @@ export function FileManagerGrid({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* File size */}
|
||||
<div className="flex-shrink-0 text-right">
|
||||
{file.type === "file" &&
|
||||
file.size !== undefined &&
|
||||
@@ -1316,7 +1180,6 @@ export function FileManagerGrid({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Permission info */}
|
||||
<div className="flex-shrink-0 text-right w-20">
|
||||
{file.permissions && (
|
||||
<p className="text-xs text-muted-foreground font-mono">
|
||||
@@ -1330,7 +1193,6 @@ export function FileManagerGrid({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Selection rectangle */}
|
||||
{isSelecting && selectionRect && (
|
||||
<div
|
||||
className="absolute pointer-events-none border-2 border-primary bg-primary/10 z-50"
|
||||
@@ -1345,7 +1207,6 @@ export function FileManagerGrid({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status bar */}
|
||||
<div className="flex-shrink-0 border-t border-dark-border px-4 py-2 text-xs text-muted-foreground">
|
||||
<div className="flex justify-between items-center">
|
||||
<span>{t("fileManager.itemCount", { count: files.length })}</span>
|
||||
@@ -1357,7 +1218,6 @@ export function FileManagerGrid({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Drag following tooltip - rendered as portal to ensure highest z-index */}
|
||||
{dragState.type === "internal" &&
|
||||
(dragState.files.length > 0 || dragState.draggedFiles?.length > 0) &&
|
||||
dragState.mousePosition &&
|
||||
@@ -1365,27 +1225,43 @@ export function FileManagerGrid({
|
||||
<div
|
||||
className="fixed pointer-events-none"
|
||||
style={{
|
||||
left: Math.min(Math.max(dragState.mousePosition.x + 40, 0), window.innerWidth - 300),
|
||||
top: Math.max(Math.min(dragState.mousePosition.y - 80, window.innerHeight - 100), 0),
|
||||
left: Math.min(
|
||||
Math.max(dragState.mousePosition.x + 40, 0),
|
||||
window.innerWidth - 300,
|
||||
),
|
||||
top: Math.max(
|
||||
Math.min(
|
||||
dragState.mousePosition.y - 80,
|
||||
window.innerHeight - 100,
|
||||
),
|
||||
0,
|
||||
),
|
||||
zIndex: 999999,
|
||||
}}
|
||||
>
|
||||
<div className="bg-background border border-border rounded-md shadow-md px-3 py-2 flex items-center gap-2">
|
||||
{(() => {
|
||||
const files = dragState.files.length > 0 ? dragState.files : dragState.draggedFiles || [];
|
||||
const files =
|
||||
dragState.files.length > 0
|
||||
? dragState.files
|
||||
: dragState.draggedFiles || [];
|
||||
return dragState.target ? (
|
||||
dragState.target.type === "directory" ? (
|
||||
<>
|
||||
<Move className="w-4 h-4 text-blue-500" />
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
{t("fileManager.moveTo", { name: dragState.target.name })}
|
||||
{t("fileManager.moveTo", {
|
||||
name: dragState.target.name,
|
||||
})}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<GitCompare className="w-4 h-4 text-purple-500" />
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
{t("fileManager.diffCompareWith", { name: dragState.target.name })}
|
||||
{t("fileManager.diffCompareWith", {
|
||||
name: dragState.target.name,
|
||||
})}
|
||||
</span>
|
||||
</>
|
||||
)
|
||||
@@ -1393,20 +1269,21 @@ export function FileManagerGrid({
|
||||
<>
|
||||
<Download className="w-4 h-4 text-green-500" />
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
{t("fileManager.dragOutsideToDownload", { count: files.length })}
|
||||
{t("fileManager.dragOutsideToDownload", {
|
||||
count: files.length,
|
||||
})}
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
document.body,
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Linus-style creation intent component: Grid view
|
||||
function CreateIntentGridItem({
|
||||
intent,
|
||||
onConfirm,
|
||||
@@ -1439,7 +1316,7 @@ function CreateIntentGridItem({
|
||||
<div className="group p-3 rounded-lg border-2 border-dashed border-primary bg-primary/10">
|
||||
<div className="flex flex-col items-center text-center">
|
||||
<div className="mb-2">
|
||||
{intent.type === 'directory' ? (
|
||||
{intent.type === "directory" ? (
|
||||
<Folder className="w-8 h-8 text-primary" />
|
||||
) : (
|
||||
<File className="w-8 h-8 text-primary" />
|
||||
@@ -1453,14 +1330,17 @@ function CreateIntentGridItem({
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={() => onConfirm?.(inputName.trim())}
|
||||
className="w-full max-w-[120px] rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-2 py-1 text-xs text-center text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[2px] outline-none"
|
||||
placeholder={intent.type === 'directory' ? t('fileManager.folderName') : t('fileManager.fileName')}
|
||||
placeholder={
|
||||
intent.type === "directory"
|
||||
? t("fileManager.folderName")
|
||||
: t("fileManager.fileName")
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Linus-style creation intent component: List view
|
||||
function CreateIntentListItem({
|
||||
intent,
|
||||
onConfirm,
|
||||
@@ -1492,7 +1372,7 @@ function CreateIntentListItem({
|
||||
return (
|
||||
<div className="flex items-center gap-3 p-2 rounded border-2 border-dashed border-primary bg-primary/10">
|
||||
<div className="flex-shrink-0">
|
||||
{intent.type === 'directory' ? (
|
||||
{intent.type === "directory" ? (
|
||||
<Folder className="w-6 h-6 text-primary" />
|
||||
) : (
|
||||
<File className="w-6 h-6 text-primary" />
|
||||
@@ -1506,7 +1386,11 @@ function CreateIntentListItem({
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={() => onConfirm?.(inputName.trim())}
|
||||
className="flex-1 min-w-0 max-w-[200px] rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-2 py-1 text-sm text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[2px] outline-none"
|
||||
placeholder={intent.type === 'directory' ? t('fileManager.folderName') : t('fileManager.fileName')}
|
||||
placeholder={
|
||||
intent.type === "directory"
|
||||
? t("fileManager.folderName")
|
||||
: t("fileManager.fileName")
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -38,9 +38,9 @@ interface FileManagerSidebarProps {
|
||||
currentPath: string;
|
||||
onPathChange: (path: string) => void;
|
||||
onLoadDirectory?: (path: string) => void;
|
||||
onFileOpen?: (file: SidebarItem) => void; // Added: handle file opening
|
||||
onFileOpen?: (file: SidebarItem) => void;
|
||||
sshSessionId?: string;
|
||||
refreshTrigger?: number; // Used to trigger data refresh
|
||||
refreshTrigger?: number;
|
||||
}
|
||||
|
||||
export function FileManagerSidebar({
|
||||
@@ -61,7 +61,6 @@ export function FileManagerSidebar({
|
||||
new Set(["root"]),
|
||||
);
|
||||
|
||||
// Right-click menu state
|
||||
const [contextMenu, setContextMenu] = useState<{
|
||||
x: number;
|
||||
y: number;
|
||||
@@ -74,12 +73,10 @@ export function FileManagerSidebar({
|
||||
item: null,
|
||||
});
|
||||
|
||||
// Load quick access data
|
||||
useEffect(() => {
|
||||
loadQuickAccessData();
|
||||
}, [currentHost, refreshTrigger]);
|
||||
|
||||
// Load directory tree (depends on sshSessionId)
|
||||
useEffect(() => {
|
||||
if (sshSessionId) {
|
||||
loadDirectoryTree();
|
||||
@@ -90,7 +87,6 @@ export function FileManagerSidebar({
|
||||
if (!currentHost?.id) return;
|
||||
|
||||
try {
|
||||
// Load recent files (limit to 5)
|
||||
const recentData = await getRecentFiles(currentHost.id);
|
||||
const recentItems = recentData.slice(0, 5).map((item: any) => ({
|
||||
id: `recent-${item.id}`,
|
||||
@@ -101,7 +97,6 @@ export function FileManagerSidebar({
|
||||
}));
|
||||
setRecentItems(recentItems);
|
||||
|
||||
// Load pinned files
|
||||
const pinnedData = await getPinnedFiles(currentHost.id);
|
||||
const pinnedItems = pinnedData.map((item: any) => ({
|
||||
id: `pinned-${item.id}`,
|
||||
@@ -111,7 +106,6 @@ export function FileManagerSidebar({
|
||||
}));
|
||||
setPinnedItems(pinnedItems);
|
||||
|
||||
// Load folder shortcuts
|
||||
const shortcutData = await getFolderShortcuts(currentHost.id);
|
||||
const shortcutItems = shortcutData.map((item: any) => ({
|
||||
id: `shortcut-${item.id}`,
|
||||
@@ -122,20 +116,18 @@ export function FileManagerSidebar({
|
||||
setShortcuts(shortcutItems);
|
||||
} catch (error) {
|
||||
console.error("Failed to load quick access data:", error);
|
||||
// If loading fails, keep empty arrays
|
||||
setRecentItems([]);
|
||||
setPinnedItems([]);
|
||||
setShortcuts([]);
|
||||
}
|
||||
};
|
||||
|
||||
// Delete functionality implementation
|
||||
const handleRemoveRecentFile = async (item: SidebarItem) => {
|
||||
if (!currentHost?.id) return;
|
||||
|
||||
try {
|
||||
await removeRecentFile(currentHost.id, item.path);
|
||||
loadQuickAccessData(); // Reload data
|
||||
loadQuickAccessData();
|
||||
toast.success(
|
||||
t("fileManager.removedFromRecentFiles", { name: item.name }),
|
||||
);
|
||||
@@ -150,7 +142,7 @@ export function FileManagerSidebar({
|
||||
|
||||
try {
|
||||
await removePinnedFile(currentHost.id, item.path);
|
||||
loadQuickAccessData(); // Reload data
|
||||
loadQuickAccessData();
|
||||
toast.success(t("fileManager.unpinnedSuccessfully", { name: item.name }));
|
||||
} catch (error) {
|
||||
console.error("Failed to unpin file:", error);
|
||||
@@ -163,7 +155,7 @@ export function FileManagerSidebar({
|
||||
|
||||
try {
|
||||
await removeFolderShortcut(currentHost.id, item.path);
|
||||
loadQuickAccessData(); // Reload data
|
||||
loadQuickAccessData();
|
||||
toast.success(t("fileManager.removedShortcut", { name: item.name }));
|
||||
} catch (error) {
|
||||
console.error("Failed to remove shortcut:", error);
|
||||
@@ -175,11 +167,10 @@ export function FileManagerSidebar({
|
||||
if (!currentHost?.id || recentItems.length === 0) return;
|
||||
|
||||
try {
|
||||
// Batch delete all recent files
|
||||
await Promise.all(
|
||||
recentItems.map((item) => removeRecentFile(currentHost.id, item.path)),
|
||||
);
|
||||
loadQuickAccessData(); // Reload data
|
||||
loadQuickAccessData();
|
||||
toast.success(t("fileManager.clearedAllRecentFiles"));
|
||||
} catch (error) {
|
||||
console.error("Failed to clear recent files:", error);
|
||||
@@ -187,7 +178,6 @@ export function FileManagerSidebar({
|
||||
}
|
||||
};
|
||||
|
||||
// Right-click menu handling
|
||||
const handleContextMenu = (e: React.MouseEvent, item: SidebarItem) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
@@ -204,7 +194,6 @@ export function FileManagerSidebar({
|
||||
setContextMenu((prev) => ({ ...prev, isVisible: false, item: null }));
|
||||
};
|
||||
|
||||
// Click outside to close menu
|
||||
useEffect(() => {
|
||||
if (!contextMenu.isVisible) return;
|
||||
|
||||
@@ -223,7 +212,6 @@ export function FileManagerSidebar({
|
||||
}
|
||||
};
|
||||
|
||||
// Delay adding listeners to avoid immediate trigger
|
||||
const timeoutId = setTimeout(() => {
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
@@ -240,10 +228,8 @@ export function FileManagerSidebar({
|
||||
if (!sshSessionId) return;
|
||||
|
||||
try {
|
||||
// Load root directory
|
||||
const response = await listSSHFiles(sshSessionId, "/");
|
||||
|
||||
// listSSHFiles now always returns {files: Array, path: string} format
|
||||
const rootFiles = response.files || [];
|
||||
const rootFolders = rootFiles.filter(
|
||||
(item: any) => item.type === "directory",
|
||||
@@ -255,7 +241,7 @@ export function FileManagerSidebar({
|
||||
path: folder.path,
|
||||
type: "folder" as const,
|
||||
isExpanded: false,
|
||||
children: [], // Subdirectories will be loaded on demand
|
||||
children: [],
|
||||
}));
|
||||
|
||||
setDirectoryTree([
|
||||
@@ -270,7 +256,6 @@ export function FileManagerSidebar({
|
||||
]);
|
||||
} catch (error) {
|
||||
console.error("Failed to load directory tree:", error);
|
||||
// If loading fails, show simple root directory
|
||||
setDirectoryTree([
|
||||
{
|
||||
id: "root",
|
||||
@@ -289,17 +274,14 @@ export function FileManagerSidebar({
|
||||
toggleFolder(item.id, item.path);
|
||||
onPathChange(item.path);
|
||||
} else if (item.type === "recent" || item.type === "pinned") {
|
||||
// For file types, call file open callback
|
||||
if (onFileOpen) {
|
||||
onFileOpen(item);
|
||||
} else {
|
||||
// If no file open callback, switch to file directory
|
||||
const directory =
|
||||
item.path.substring(0, item.path.lastIndexOf("/")) || "/";
|
||||
onPathChange(directory);
|
||||
}
|
||||
} else if (item.type === "shortcut") {
|
||||
// Folder shortcuts directly switch to directory
|
||||
onPathChange(item.path);
|
||||
}
|
||||
};
|
||||
@@ -312,12 +294,10 @@ export function FileManagerSidebar({
|
||||
} else {
|
||||
newExpanded.add(folderId);
|
||||
|
||||
// Load subdirectories on demand
|
||||
if (sshSessionId && folderPath && folderPath !== "/") {
|
||||
try {
|
||||
const subResponse = await listSSHFiles(sshSessionId, folderPath);
|
||||
|
||||
// listSSHFiles now always returns {files: Array, path: string} format
|
||||
const subFiles = subResponse.files || [];
|
||||
const subFolders = subFiles.filter(
|
||||
(item: any) => item.type === "directory",
|
||||
@@ -332,7 +312,6 @@ export function FileManagerSidebar({
|
||||
children: [],
|
||||
}));
|
||||
|
||||
// Update directory tree, add subdirectories for current folder
|
||||
setDirectoryTree((prevTree) => {
|
||||
const updateChildren = (items: SidebarItem[]): SidebarItem[] => {
|
||||
return items.map((item) => {
|
||||
@@ -370,7 +349,6 @@ export function FileManagerSidebar({
|
||||
style={{ paddingLeft: `${12 + level * 16}px`, paddingRight: "12px" }}
|
||||
onClick={() => handleItemClick(item)}
|
||||
onContextMenu={(e) => {
|
||||
// Only quick access items need right-click menu
|
||||
if (
|
||||
item.type === "recent" ||
|
||||
item.type === "pinned" ||
|
||||
@@ -438,7 +416,6 @@ export function FileManagerSidebar({
|
||||
);
|
||||
};
|
||||
|
||||
// Check if there are any quick access items
|
||||
const hasQuickAccessItems =
|
||||
recentItems.length > 0 || pinnedItems.length > 0 || shortcuts.length > 0;
|
||||
|
||||
@@ -447,7 +424,6 @@ export function FileManagerSidebar({
|
||||
<div className="h-full flex flex-col bg-dark-bg border-r border-dark-border">
|
||||
<div className="flex-1 relative overflow-hidden">
|
||||
<div className="absolute inset-1.5 overflow-y-auto thin-scrollbar space-y-4">
|
||||
{/* Quick access area */}
|
||||
{renderSection(
|
||||
t("fileManager.recent"),
|
||||
<Clock className="w-3 h-3" />,
|
||||
@@ -464,7 +440,6 @@ export function FileManagerSidebar({
|
||||
shortcuts,
|
||||
)}
|
||||
|
||||
{/* Directory tree */}
|
||||
<div
|
||||
className={cn(
|
||||
hasQuickAccessItems && "pt-4 border-t border-dark-border",
|
||||
@@ -482,7 +457,6 @@ export function FileManagerSidebar({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right-click menu */}
|
||||
{contextMenu.isVisible && contextMenu.item && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-40" />
|
||||
|
||||
@@ -33,8 +33,6 @@ export function DiffViewer({
|
||||
file2,
|
||||
sshSessionId,
|
||||
sshHost,
|
||||
onDownload1,
|
||||
onDownload2,
|
||||
}: DiffViewerProps) {
|
||||
const { t } = useTranslation();
|
||||
const [content1, setContent1] = useState<string>("");
|
||||
@@ -46,7 +44,6 @@ export function DiffViewer({
|
||||
);
|
||||
const [showLineNumbers, setShowLineNumbers] = useState(true);
|
||||
|
||||
// Ensure SSH connection is valid
|
||||
const ensureSSHConnection = async () => {
|
||||
try {
|
||||
const status = await getSSHStatus(sshSessionId);
|
||||
@@ -70,7 +67,6 @@ export function DiffViewer({
|
||||
}
|
||||
};
|
||||
|
||||
// Load file contents
|
||||
const loadFileContents = async () => {
|
||||
if (file1.type !== "file" || file2.type !== "file") {
|
||||
setError(t("fileManager.canOnlyCompareFiles"));
|
||||
@@ -81,10 +77,8 @@ export function DiffViewer({
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
// Ensure SSH connection is valid
|
||||
await ensureSSHConnection();
|
||||
|
||||
// Load both files in parallel
|
||||
const [response1, response2] = await Promise.all([
|
||||
readSSHFile(sshSessionId, file1.path),
|
||||
readSSHFile(sshSessionId, file2.path),
|
||||
@@ -106,13 +100,16 @@ export function DiffViewer({
|
||||
t("fileManager.sshConnectionFailed", {
|
||||
name: sshHost.name,
|
||||
ip: sshHost.ip,
|
||||
port: sshHost.port
|
||||
port: sshHost.port,
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
setError(
|
||||
t("fileManager.loadFileFailed", {
|
||||
error: error.message || errorData?.error || t("fileManager.unknownError")
|
||||
error:
|
||||
error.message ||
|
||||
errorData?.error ||
|
||||
t("fileManager.unknownError"),
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -121,7 +118,6 @@ export function DiffViewer({
|
||||
}
|
||||
};
|
||||
|
||||
// Download file
|
||||
const handleDownloadFile = async (file: FileItem) => {
|
||||
try {
|
||||
await ensureSSHConnection();
|
||||
@@ -147,15 +143,20 @@ export function DiffViewer({
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
toast.success(t("fileManager.downloadFileSuccess", { name: file.name }));
|
||||
toast.success(
|
||||
t("fileManager.downloadFileSuccess", { name: file.name }),
|
||||
);
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("Failed to download file:", error);
|
||||
toast.error(t("fileManager.downloadFileFailed") + ": " + (error.message || t("fileManager.unknownError")));
|
||||
toast.error(
|
||||
t("fileManager.downloadFileFailed") +
|
||||
": " +
|
||||
(error.message || t("fileManager.unknownError")),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Get file language type
|
||||
const getFileLanguage = (fileName: string): string => {
|
||||
const ext = fileName.split(".").pop()?.toLowerCase();
|
||||
const languageMap: Record<string, string> = {
|
||||
@@ -190,7 +191,6 @@ export function DiffViewer({
|
||||
return languageMap[ext || ""] || "plaintext";
|
||||
};
|
||||
|
||||
// Initial load
|
||||
useEffect(() => {
|
||||
loadFileContents();
|
||||
}, [file1, file2, sshSessionId]);
|
||||
@@ -200,7 +200,9 @@ export function DiffViewer({
|
||||
<div className="h-full flex items-center justify-center bg-dark-bg">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500 mx-auto mb-2"></div>
|
||||
<p className="text-sm text-muted-foreground">{t("fileManager.loadingFileComparison")}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("fileManager.loadingFileComparison")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -223,12 +225,13 @@ export function DiffViewer({
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col bg-dark-bg">
|
||||
{/* Toolbar */}
|
||||
<div className="flex-shrink-0 border-b border-dark-border p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="text-sm">
|
||||
<span className="text-muted-foreground">{t("fileManager.compare")}:</span>
|
||||
<span className="text-muted-foreground">
|
||||
{t("fileManager.compare")}:
|
||||
</span>
|
||||
<span className="font-medium text-green-400 mx-2">
|
||||
{file1.name}
|
||||
</span>
|
||||
@@ -238,7 +241,6 @@ export function DiffViewer({
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{/* View toggle */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@@ -248,10 +250,11 @@ export function DiffViewer({
|
||||
)
|
||||
}
|
||||
>
|
||||
{diffMode === "side-by-side" ? t("fileManager.sideBySide") : t("fileManager.inline")}
|
||||
{diffMode === "side-by-side"
|
||||
? t("fileManager.sideBySide")
|
||||
: t("fileManager.inline")}
|
||||
</Button>
|
||||
|
||||
{/* Line number toggle */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@@ -264,7 +267,6 @@ export function DiffViewer({
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{/* Download buttons */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@@ -285,7 +287,6 @@ export function DiffViewer({
|
||||
{file2.name}
|
||||
</Button>
|
||||
|
||||
{/* Refresh button */}
|
||||
<Button variant="outline" size="sm" onClick={loadFileContents}>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
</Button>
|
||||
@@ -293,7 +294,6 @@ export function DiffViewer({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Diff editor */}
|
||||
<div className="flex-1">
|
||||
<DiffEditor
|
||||
original={content1}
|
||||
@@ -322,7 +322,9 @@ export function DiffViewer({
|
||||
<div className="h-full flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-500 mx-auto mb-2"></div>
|
||||
<p className="text-sm text-muted-foreground">{t("fileManager.initializingEditor")}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("fileManager.initializingEditor")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -30,7 +30,6 @@ export function DiffWindow({
|
||||
|
||||
const currentWindow = windows.find((w) => w.id === windowId);
|
||||
|
||||
// Window operation handling
|
||||
const handleClose = () => {
|
||||
closeWindow(windowId);
|
||||
};
|
||||
@@ -49,7 +48,10 @@ export function DiffWindow({
|
||||
|
||||
return (
|
||||
<DraggableWindow
|
||||
title={t("fileManager.fileComparison", { file1: file1.name, file2: file2.name })}
|
||||
title={t("fileManager.fileComparison", {
|
||||
file1: file1.name,
|
||||
file2: file2.name,
|
||||
})}
|
||||
initialX={initialX}
|
||||
initialY={initialY}
|
||||
initialWidth={1200}
|
||||
|
||||
@@ -39,7 +39,6 @@ export function DraggableWindow({
|
||||
targetSize,
|
||||
}: DraggableWindowProps) {
|
||||
const { t } = useTranslation();
|
||||
// Window state
|
||||
const [position, setPosition] = useState({ x: initialX, y: initialY });
|
||||
const [size, setSize] = useState({
|
||||
width: initialWidth,
|
||||
@@ -49,7 +48,6 @@ export function DraggableWindow({
|
||||
const [isResizing, setIsResizing] = useState(false);
|
||||
const [resizeDirection, setResizeDirection] = useState<string>("");
|
||||
|
||||
// Drag and resize start positions
|
||||
const [dragStart, setDragStart] = useState({ x: 0, y: 0 });
|
||||
const [windowStart, setWindowStart] = useState({ x: 0, y: 0 });
|
||||
const [sizeStart, setSizeStart] = useState({ width: 0, height: 0 });
|
||||
@@ -57,17 +55,14 @@ export function DraggableWindow({
|
||||
const windowRef = useRef<HTMLDivElement>(null);
|
||||
const titleBarRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Handle target size changes for media files
|
||||
useEffect(() => {
|
||||
if (targetSize && !isMaximized) {
|
||||
const maxWidth = Math.min(window.innerWidth * 0.9, 1200);
|
||||
const maxHeight = Math.min(window.innerHeight * 0.8, 800);
|
||||
|
||||
// Calculate appropriate window size maintaining aspect ratio
|
||||
let newWidth = Math.min(targetSize.width + 50, maxWidth); // Add padding for UI
|
||||
let newHeight = Math.min(targetSize.height + 150, maxHeight); // Add padding for header/footer
|
||||
let newWidth = Math.min(targetSize.width + 50, maxWidth);
|
||||
let newHeight = Math.min(targetSize.height + 150, maxHeight);
|
||||
|
||||
// If still too large, scale down maintaining aspect ratio
|
||||
if (newWidth > maxWidth || newHeight > maxHeight) {
|
||||
const widthRatio = maxWidth / newWidth;
|
||||
const heightRatio = maxHeight / newHeight;
|
||||
@@ -77,26 +72,22 @@ export function DraggableWindow({
|
||||
newHeight = Math.floor(newHeight * scale);
|
||||
}
|
||||
|
||||
// Ensure minimum size
|
||||
newWidth = Math.max(newWidth, minWidth);
|
||||
newHeight = Math.max(newHeight, minHeight);
|
||||
|
||||
setSize({ width: newWidth, height: newHeight });
|
||||
|
||||
// Center the window
|
||||
setPosition({
|
||||
x: Math.max(0, (window.innerWidth - newWidth) / 2),
|
||||
y: Math.max(0, (window.innerHeight - newHeight) / 2)
|
||||
y: Math.max(0, (window.innerHeight - newHeight) / 2),
|
||||
});
|
||||
}
|
||||
}, [targetSize, isMaximized, minWidth, minHeight]);
|
||||
|
||||
// Handle window focus
|
||||
const handleWindowClick = useCallback(() => {
|
||||
onFocus?.();
|
||||
}, [onFocus]);
|
||||
|
||||
// Drag handling
|
||||
const handleMouseDown = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (isMaximized) return;
|
||||
@@ -119,44 +110,44 @@ export function DraggableWindow({
|
||||
const newX = windowStart.x + deltaX;
|
||||
const newY = windowStart.y + deltaY;
|
||||
|
||||
// Find the positioning container by checking parent hierarchy
|
||||
const windowElement = windowRef.current;
|
||||
let positioningContainer = null;
|
||||
let currentElement = windowElement?.parentElement;
|
||||
|
||||
|
||||
while (currentElement && currentElement !== document.body) {
|
||||
const computedStyle = window.getComputedStyle(currentElement);
|
||||
const position = computedStyle.position;
|
||||
const transform = computedStyle.transform;
|
||||
|
||||
if (position === 'relative' || position === 'absolute' || position === 'fixed' || transform !== 'none') {
|
||||
|
||||
if (
|
||||
position === "relative" ||
|
||||
position === "absolute" ||
|
||||
position === "fixed" ||
|
||||
transform !== "none"
|
||||
) {
|
||||
positioningContainer = currentElement;
|
||||
break;
|
||||
}
|
||||
|
||||
|
||||
currentElement = currentElement.parentElement;
|
||||
}
|
||||
|
||||
// Calculate boundaries based on the actual positioning context
|
||||
let maxX, maxY, minX, minY;
|
||||
|
||||
|
||||
if (positioningContainer) {
|
||||
const containerRect = positioningContainer.getBoundingClientRect();
|
||||
|
||||
// Window is positioned relative to a positioning container
|
||||
|
||||
maxX = containerRect.width - size.width;
|
||||
maxY = containerRect.height - size.height;
|
||||
minX = 0;
|
||||
minY = 0;
|
||||
} else {
|
||||
// Window is positioned relative to viewport
|
||||
maxX = window.innerWidth - size.width;
|
||||
maxY = window.innerHeight - size.height;
|
||||
minX = 0;
|
||||
minY = 0;
|
||||
}
|
||||
|
||||
// Ensure window stays within boundaries
|
||||
const constrainedX = Math.max(minX, Math.min(maxX, newX));
|
||||
const constrainedY = Math.max(minY, Math.min(maxY, newY));
|
||||
|
||||
@@ -175,14 +166,12 @@ export function DraggableWindow({
|
||||
let newX = windowStart.x;
|
||||
let newY = windowStart.y;
|
||||
|
||||
// Handle horizontal resizing
|
||||
if (resizeDirection.includes("right")) {
|
||||
newWidth = Math.max(minWidth, sizeStart.width + deltaX);
|
||||
}
|
||||
if (resizeDirection.includes("left")) {
|
||||
const widthChange = -deltaX;
|
||||
newWidth = Math.max(minWidth, sizeStart.width + widthChange);
|
||||
// Only move position if we're actually changing size
|
||||
if (newWidth > minWidth || widthChange > 0) {
|
||||
newX = windowStart.x - (newWidth - sizeStart.width);
|
||||
} else {
|
||||
@@ -190,14 +179,12 @@ export function DraggableWindow({
|
||||
}
|
||||
}
|
||||
|
||||
// Handle vertical resizing
|
||||
if (resizeDirection.includes("bottom")) {
|
||||
newHeight = Math.max(minHeight, sizeStart.height + deltaY);
|
||||
}
|
||||
if (resizeDirection.includes("top")) {
|
||||
const heightChange = -deltaY;
|
||||
newHeight = Math.max(minHeight, sizeStart.height + heightChange);
|
||||
// Only move position if we're actually changing size
|
||||
if (newHeight > minHeight || heightChange > 0) {
|
||||
newY = windowStart.y - (newHeight - sizeStart.height);
|
||||
} else {
|
||||
@@ -205,7 +192,6 @@ export function DraggableWindow({
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure window stays within viewport
|
||||
newX = Math.max(0, Math.min(window.innerWidth - newWidth, newX));
|
||||
newY = Math.max(0, Math.min(window.innerHeight - newHeight, newY));
|
||||
|
||||
@@ -234,7 +220,6 @@ export function DraggableWindow({
|
||||
setResizeDirection("");
|
||||
}, []);
|
||||
|
||||
// Resize handling
|
||||
const handleResizeStart = useCallback(
|
||||
(e: React.MouseEvent, direction: string) => {
|
||||
if (isMaximized) return;
|
||||
@@ -251,7 +236,6 @@ export function DraggableWindow({
|
||||
[isMaximized, position, size, onFocus],
|
||||
);
|
||||
|
||||
// Global event listeners
|
||||
useEffect(() => {
|
||||
if (isDragging || isResizing) {
|
||||
document.addEventListener("mousemove", handleMouseMove);
|
||||
@@ -268,7 +252,6 @@ export function DraggableWindow({
|
||||
}
|
||||
}, [isDragging, isResizing, handleMouseMove, handleMouseUp]);
|
||||
|
||||
// Double-click title bar to maximize/restore
|
||||
const handleTitleDoubleClick = useCallback(() => {
|
||||
onMaximize?.();
|
||||
}, [onMaximize]);
|
||||
@@ -290,7 +273,6 @@ export function DraggableWindow({
|
||||
}}
|
||||
onClick={handleWindowClick}
|
||||
>
|
||||
{/* Title bar */}
|
||||
<div
|
||||
ref={titleBarRef}
|
||||
className={cn(
|
||||
@@ -349,7 +331,6 @@ export function DraggableWindow({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Window content */}
|
||||
<div
|
||||
className="flex-1 overflow-auto"
|
||||
style={{ height: "calc(100% - 40px)" }}
|
||||
@@ -357,10 +338,8 @@ export function DraggableWindow({
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{/* Resize borders - only show when not maximized */}
|
||||
{!isMaximized && (
|
||||
<>
|
||||
{/* Edge resize */}
|
||||
<div
|
||||
className="absolute top-0 left-0 right-0 h-1 cursor-n-resize"
|
||||
onMouseDown={(e) => handleResizeStart(e, "top")}
|
||||
@@ -378,7 +357,6 @@ export function DraggableWindow({
|
||||
onMouseDown={(e) => handleResizeStart(e, "right")}
|
||||
/>
|
||||
|
||||
{/* Corner resize */}
|
||||
<div
|
||||
className="absolute top-0 left-0 w-2 h-2 cursor-nw-resize"
|
||||
onMouseDown={(e) => handleResizeStart(e, "top-left")}
|
||||
|
||||
@@ -51,7 +51,12 @@ import { oneDark } from "@codemirror/theme-one-dark";
|
||||
import { loadLanguage } from "@uiw/codemirror-extensions-langs";
|
||||
import { EditorView, keymap } from "@codemirror/view";
|
||||
import { searchKeymap, search, openSearchPanel } from "@codemirror/search";
|
||||
import { defaultKeymap, history, historyKeymap, toggleComment } from "@codemirror/commands";
|
||||
import {
|
||||
defaultKeymap,
|
||||
history,
|
||||
historyKeymap,
|
||||
toggleComment,
|
||||
} from "@codemirror/commands";
|
||||
import { autocompletion, completionKeymap } from "@codemirror/autocomplete";
|
||||
import { PhotoProvider, PhotoView } from "react-photo-view";
|
||||
import "react-photo-view/dist/react-photo-view.css";
|
||||
@@ -64,8 +69,7 @@ import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
|
||||
import { oneDark as syntaxTheme } from "react-syntax-highlighter/dist/esm/styles/prism";
|
||||
import { Document, Page, pdfjs } from "react-pdf";
|
||||
|
||||
// Use local PDF.js worker to avoid CDN issues
|
||||
pdfjs.GlobalWorkerOptions.workerSrc = '/pdf.worker.min.js';
|
||||
pdfjs.GlobalWorkerOptions.workerSrc = "/pdf.worker.min.js";
|
||||
|
||||
interface FileItem {
|
||||
name: string;
|
||||
@@ -87,15 +91,16 @@ interface FileViewerProps {
|
||||
onContentChange?: (content: string) => void;
|
||||
onSave?: (content: string) => void;
|
||||
onDownload?: () => void;
|
||||
onMediaDimensionsChange?: (dimensions: { width: number; height: number }) => void;
|
||||
onMediaDimensionsChange?: (dimensions: {
|
||||
width: number;
|
||||
height: number;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
// Get official icon for programming languages
|
||||
function getLanguageIcon(filename: string): React.ReactNode {
|
||||
const ext = filename.split(".").pop()?.toLowerCase() || "";
|
||||
const baseName = filename.toLowerCase();
|
||||
|
||||
// Special filename handling
|
||||
if (["dockerfile"].includes(baseName)) {
|
||||
return <SiDocker className="w-6 h-6 text-blue-400" />;
|
||||
}
|
||||
@@ -141,7 +146,6 @@ function getLanguageIcon(filename: string): React.ReactNode {
|
||||
return iconMap[ext] || <Code className="w-6 h-6 text-yellow-500" />;
|
||||
}
|
||||
|
||||
// Get file type and icon
|
||||
function getFileType(filename: string): {
|
||||
type: string;
|
||||
icon: React.ReactNode;
|
||||
@@ -239,17 +243,14 @@ function getFileType(filename: string): {
|
||||
}
|
||||
}
|
||||
|
||||
// Get CodeMirror language extension
|
||||
function getLanguageExtension(filename: string) {
|
||||
const ext = filename.split(".").pop()?.toLowerCase() || "";
|
||||
const baseName = filename.toLowerCase();
|
||||
|
||||
// Special filename handling
|
||||
if (["dockerfile", "makefile", "rakefile", "gemfile"].includes(baseName)) {
|
||||
return loadLanguage(baseName);
|
||||
}
|
||||
|
||||
// Map by file extension
|
||||
const langMap: Record<string, string> = {
|
||||
js: "javascript",
|
||||
jsx: "jsx",
|
||||
@@ -288,7 +289,6 @@ function getLanguageExtension(filename: string) {
|
||||
return language ? loadLanguage(language) : null;
|
||||
}
|
||||
|
||||
// Format file size
|
||||
function formatFileSize(bytes?: number, t?: any): string {
|
||||
if (!bytes) return t ? t("fileManager.unknownSize") : "Unknown size";
|
||||
const sizes = ["B", "KB", "MB", "GB"];
|
||||
@@ -328,32 +328,25 @@ export function FileViewer({
|
||||
|
||||
const fileTypeInfo = getFileType(file.name);
|
||||
|
||||
// File size limits - remove hard limits, support large file handling
|
||||
const WARNING_SIZE = 50 * 1024 * 1024; // 50MB warning
|
||||
const MAX_SIZE = Number.MAX_SAFE_INTEGER; // Remove hard limits
|
||||
const WARNING_SIZE = 50 * 1024 * 1024;
|
||||
const MAX_SIZE = Number.MAX_SAFE_INTEGER;
|
||||
|
||||
// Check if should display as text
|
||||
const shouldShowAsText =
|
||||
fileTypeInfo.type === "text" ||
|
||||
fileTypeInfo.type === "code" ||
|
||||
(fileTypeInfo.type === "unknown" &&
|
||||
(forceShowAsText || !file.size || file.size <= WARNING_SIZE));
|
||||
|
||||
// Check if file is too large
|
||||
const isLargeFile = file.size && file.size > WARNING_SIZE;
|
||||
const isTooLarge = file.size && file.size > MAX_SIZE;
|
||||
|
||||
// Sync external content changes
|
||||
useEffect(() => {
|
||||
setEditedContent(content);
|
||||
// Only update originalContent when savedContent is updated
|
||||
if (savedContent) {
|
||||
setOriginalContent(savedContent);
|
||||
}
|
||||
// Fix: Compare current content with saved content properly
|
||||
setHasChanges(content !== savedContent);
|
||||
|
||||
// If unknown file type and file is large, show warning
|
||||
if (fileTypeInfo.type === "unknown" && isLargeFile && !forceShowAsText) {
|
||||
setShowLargeFileWarning(true);
|
||||
} else {
|
||||
@@ -361,59 +354,46 @@ export function FileViewer({
|
||||
}
|
||||
}, [content, savedContent, fileTypeInfo.type, isLargeFile, forceShowAsText]);
|
||||
|
||||
// Handle content changes
|
||||
const handleContentChange = (newContent: string) => {
|
||||
setEditedContent(newContent);
|
||||
// Fix: Compare with savedContent instead of originalContent for consistency
|
||||
setHasChanges(newContent !== savedContent);
|
||||
onContentChange?.(newContent);
|
||||
};
|
||||
|
||||
// Save file
|
||||
const handleSave = () => {
|
||||
onSave?.(editedContent);
|
||||
// Note: Don't update originalContent here, as it will be updated via savedContent prop
|
||||
};
|
||||
|
||||
// Revert file
|
||||
const handleRevert = () => {
|
||||
setEditedContent(savedContent);
|
||||
setHasChanges(false);
|
||||
onContentChange?.(savedContent);
|
||||
};
|
||||
|
||||
// Handle save shortcut specifically
|
||||
useEffect(() => {
|
||||
if (!editorFocused || !isEditable) return;
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
// Only handle Ctrl+S for custom save, let CodeMirror handle everything else
|
||||
const isCtrl = e.ctrlKey || e.metaKey;
|
||||
if (isCtrl && e.key.toLowerCase() === 's') {
|
||||
if (isCtrl && e.key.toLowerCase() === "s") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleSave();
|
||||
}
|
||||
};
|
||||
|
||||
// Add event listener with capture for save shortcut only
|
||||
document.addEventListener('keydown', handleKeyDown, true);
|
||||
document.addEventListener("keydown", handleKeyDown, true);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown, true);
|
||||
document.removeEventListener("keydown", handleKeyDown, true);
|
||||
};
|
||||
}, [editorFocused, isEditable, handleSave]);
|
||||
|
||||
|
||||
|
||||
|
||||
// Handle user confirmation to open large file
|
||||
const handleConfirmOpenAsText = () => {
|
||||
setForceShowAsText(true);
|
||||
setShowLargeFileWarning(false);
|
||||
};
|
||||
|
||||
// Handle user rejection to open large file
|
||||
const handleCancelOpenAsText = () => {
|
||||
setShowLargeFileWarning(false);
|
||||
};
|
||||
@@ -431,7 +411,6 @@ export function FileViewer({
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col bg-background">
|
||||
{/* File info header */}
|
||||
<div className="flex-shrink-0 bg-card border-b border-border p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -442,7 +421,11 @@ export function FileViewer({
|
||||
<h3 className="font-medium text-foreground">{file.name}</h3>
|
||||
<div className="flex items-center gap-4 text-sm text-muted-foreground">
|
||||
<span>{formatFileSize(file.size, t)}</span>
|
||||
{file.modified && <span>{t("fileManager.modified")}: {file.modified}</span>}
|
||||
{file.modified && (
|
||||
<span>
|
||||
{t("fileManager.modified")}: {file.modified}
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className={cn(
|
||||
"px-2 py-1 rounded-full text-xs",
|
||||
@@ -457,13 +440,11 @@ export function FileViewer({
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Search button */}
|
||||
{isEditable && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
// Use CodeMirror's proper API to open search panel
|
||||
if (editorRef.current) {
|
||||
const view = editorRef.current.view;
|
||||
if (view) {
|
||||
@@ -477,7 +458,6 @@ export function FileViewer({
|
||||
<Search className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
{/* Keyboard shortcuts help */}
|
||||
{isEditable && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -526,11 +506,12 @@ export function FileViewer({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Keyboard shortcuts help panel */}
|
||||
{showKeyboardShortcuts && isEditable && (
|
||||
<div className="flex-shrink-0 bg-muted/30 border-b border-border p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-semibold">{t("fileManager.keyboardShortcuts")}</h3>
|
||||
<h3 className="text-sm font-semibold">
|
||||
{t("fileManager.keyboardShortcuts")}
|
||||
</h3>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@@ -542,60 +523,88 @@ export function FileViewer({
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 text-xs">
|
||||
<div className="space-y-2">
|
||||
<h4 className="font-medium text-muted-foreground">{t("fileManager.searchAndReplace")}</h4>
|
||||
<h4 className="font-medium text-muted-foreground">
|
||||
{t("fileManager.searchAndReplace")}
|
||||
</h4>
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between">
|
||||
<span>{t("fileManager.search")}</span>
|
||||
<kbd className="px-2 py-1 bg-background rounded text-xs">Ctrl+F</kbd>
|
||||
<kbd className="px-2 py-1 bg-background rounded text-xs">
|
||||
Ctrl+F
|
||||
</kbd>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>{t("fileManager.replace")}</span>
|
||||
<kbd className="px-2 py-1 bg-background rounded text-xs">Ctrl+H</kbd>
|
||||
<kbd className="px-2 py-1 bg-background rounded text-xs">
|
||||
Ctrl+H
|
||||
</kbd>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>{t("fileManager.findNext")}</span>
|
||||
<kbd className="px-2 py-1 bg-background rounded text-xs">F3</kbd>
|
||||
<kbd className="px-2 py-1 bg-background rounded text-xs">
|
||||
F3
|
||||
</kbd>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>{t("fileManager.findPrevious")}</span>
|
||||
<kbd className="px-2 py-1 bg-background rounded text-xs">Shift+F3</kbd>
|
||||
<kbd className="px-2 py-1 bg-background rounded text-xs">
|
||||
Shift+F3
|
||||
</kbd>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<h4 className="font-medium text-muted-foreground">{t("fileManager.editing")}</h4>
|
||||
<h4 className="font-medium text-muted-foreground">
|
||||
{t("fileManager.editing")}
|
||||
</h4>
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between">
|
||||
<span>{t("fileManager.save")}</span>
|
||||
<kbd className="px-2 py-1 bg-background rounded text-xs">Ctrl+S</kbd>
|
||||
<kbd className="px-2 py-1 bg-background rounded text-xs">
|
||||
Ctrl+S
|
||||
</kbd>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>{t("fileManager.selectAll")}</span>
|
||||
<kbd className="px-2 py-1 bg-background rounded text-xs">Ctrl+A</kbd>
|
||||
<kbd className="px-2 py-1 bg-background rounded text-xs">
|
||||
Ctrl+A
|
||||
</kbd>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>{t("fileManager.undo")}</span>
|
||||
<kbd className="px-2 py-1 bg-background rounded text-xs">Ctrl+Z</kbd>
|
||||
<kbd className="px-2 py-1 bg-background rounded text-xs">
|
||||
Ctrl+Z
|
||||
</kbd>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>{t("fileManager.redo")}</span>
|
||||
<kbd className="px-2 py-1 bg-background rounded text-xs">Ctrl+Y</kbd>
|
||||
<kbd className="px-2 py-1 bg-background rounded text-xs">
|
||||
Ctrl+Y
|
||||
</kbd>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>{t("fileManager.toggleComment")}</span>
|
||||
<kbd className="px-2 py-1 bg-background rounded text-xs">Ctrl+/</kbd>
|
||||
<kbd className="px-2 py-1 bg-background rounded text-xs">
|
||||
Ctrl+/
|
||||
</kbd>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>{t("fileManager.autoComplete")}</span>
|
||||
<kbd className="px-2 py-1 bg-background rounded text-xs">Ctrl+Space</kbd>
|
||||
<kbd className="px-2 py-1 bg-background rounded text-xs">
|
||||
Ctrl+Space
|
||||
</kbd>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>{t("fileManager.moveLineUp")}</span>
|
||||
<kbd className="px-2 py-1 bg-background rounded text-xs">Alt+↑</kbd>
|
||||
<kbd className="px-2 py-1 bg-background rounded text-xs">
|
||||
Alt+↑
|
||||
</kbd>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>{t("fileManager.moveLineDown")}</span>
|
||||
<kbd className="px-2 py-1 bg-background rounded text-xs">Alt+↓</kbd>
|
||||
<kbd className="px-2 py-1 bg-background rounded text-xs">
|
||||
Alt+↓
|
||||
</kbd>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -603,9 +612,7 @@ export function FileViewer({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* File content */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{/* Large file warning dialog */}
|
||||
{showLargeFileWarning && (
|
||||
<div className="h-full flex items-center justify-center bg-background">
|
||||
<div className="bg-card border border-destructive/30 rounded-lg p-6 max-w-md mx-4 shadow-lg">
|
||||
@@ -616,7 +623,9 @@ export function FileViewer({
|
||||
{t("fileManager.largeFileWarning")}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground mb-3">
|
||||
{t("fileManager.largeFileWarningDesc", { size: formatFileSize(file.size, t) })}
|
||||
{t("fileManager.largeFileWarningDesc", {
|
||||
size: formatFileSize(file.size, t),
|
||||
})}
|
||||
</p>
|
||||
{isTooLarge ? (
|
||||
<div className="bg-destructive/10 border border-destructive/30 rounded p-3 mb-4">
|
||||
@@ -667,19 +676,15 @@ export function FileViewer({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Image preview with react-photo-view */}
|
||||
{fileTypeInfo.type === "image" && !showLargeFileWarning && (
|
||||
<div className="p-6 flex items-center justify-center h-full relative">
|
||||
{imageLoadError ? (
|
||||
// Error state
|
||||
<div className="text-center text-muted-foreground">
|
||||
<AlertCircle className="w-16 h-16 mx-auto mb-4 text-muted-foreground/50" />
|
||||
<h3 className="text-lg font-medium mb-2">
|
||||
{t("fileManager.imageLoadError")}
|
||||
</h3>
|
||||
<p className="text-sm mb-4">
|
||||
{file.name}
|
||||
</p>
|
||||
<p className="text-sm mb-4">{file.name}</p>
|
||||
{onDownload && (
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -703,12 +708,15 @@ export function FileViewer({
|
||||
setImageLoading(false);
|
||||
setImageLoadError(false);
|
||||
|
||||
// Get natural dimensions and notify parent
|
||||
const img = e.currentTarget;
|
||||
if (onMediaDimensionsChange && img.naturalWidth && img.naturalHeight) {
|
||||
if (
|
||||
onMediaDimensionsChange &&
|
||||
img.naturalWidth &&
|
||||
img.naturalHeight
|
||||
) {
|
||||
onMediaDimensionsChange({
|
||||
width: img.naturalWidth,
|
||||
height: img.naturalHeight
|
||||
height: img.naturalHeight,
|
||||
});
|
||||
}
|
||||
}}
|
||||
@@ -721,23 +729,22 @@ export function FileViewer({
|
||||
</PhotoProvider>
|
||||
)}
|
||||
|
||||
{/* Loading state */}
|
||||
{imageLoading && !imageLoadError && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-background/80">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500 mx-auto mb-2"></div>
|
||||
<p className="text-sm text-muted-foreground">Loading image...</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Loading image...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Unified text and code file editor */}
|
||||
{shouldShowAsText && !showLargeFileWarning && (
|
||||
<div className="h-full flex flex-col">
|
||||
{isEditable ? (
|
||||
// Unified CodeMirror editor for all text-based files
|
||||
<CodeMirror
|
||||
ref={editorRef}
|
||||
value={editedContent}
|
||||
@@ -756,20 +763,18 @@ export function FileViewer({
|
||||
...searchKeymap,
|
||||
...historyKeymap,
|
||||
...completionKeymap,
|
||||
// Custom keybindings
|
||||
{
|
||||
key: "Mod-/",
|
||||
run: toggleComment,
|
||||
preventDefault: true
|
||||
preventDefault: true,
|
||||
},
|
||||
{
|
||||
key: "Mod-h",
|
||||
run: () => {
|
||||
// Let CodeMirror search handle this, just prevent browser default
|
||||
return false; // Return false to let search keymap handle it
|
||||
return false;
|
||||
},
|
||||
preventDefault: true
|
||||
}
|
||||
preventDefault: true,
|
||||
},
|
||||
]),
|
||||
EditorView.theme({
|
||||
"&": {
|
||||
@@ -800,7 +805,6 @@ export function FileViewer({
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
// Read-only view for non-editable files
|
||||
<div className="h-full p-4 font-mono text-sm whitespace-pre-wrap overflow-auto bg-background text-foreground">
|
||||
{editedContent || content || t("fileManager.fileIsEmpty")}
|
||||
</div>
|
||||
@@ -808,22 +812,29 @@ export function FileViewer({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Video file preview with enhanced HTML5 support */}
|
||||
{fileTypeInfo.type === "video" && !showLargeFileWarning && (
|
||||
<div className="p-6 flex items-center justify-center h-full">
|
||||
<div className="w-full max-w-4xl">
|
||||
{(() => {
|
||||
const ext = file.name.split('.').pop()?.toLowerCase() || '';
|
||||
const ext = file.name.split(".").pop()?.toLowerCase() || "";
|
||||
const mimeType = (() => {
|
||||
switch (ext) {
|
||||
case 'mp4': return 'video/mp4';
|
||||
case 'webm': return 'video/webm';
|
||||
case 'mkv': return 'video/x-matroska';
|
||||
case 'avi': return 'video/x-msvideo';
|
||||
case 'mov': return 'video/quicktime';
|
||||
case 'wmv': return 'video/x-ms-wmv';
|
||||
case 'flv': return 'video/x-flv';
|
||||
default: return 'video/mp4';
|
||||
case "mp4":
|
||||
return "video/mp4";
|
||||
case "webm":
|
||||
return "video/webm";
|
||||
case "mkv":
|
||||
return "video/x-matroska";
|
||||
case "avi":
|
||||
return "video/x-msvideo";
|
||||
case "mov":
|
||||
return "video/quicktime";
|
||||
case "wmv":
|
||||
return "video/x-ms-wmv";
|
||||
case "flv":
|
||||
return "video/x-flv";
|
||||
default:
|
||||
return "video/mp4";
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -836,35 +847,36 @@ export function FileViewer({
|
||||
className="w-full rounded-lg shadow-sm"
|
||||
style={{
|
||||
maxHeight: "calc(100vh - 200px)",
|
||||
backgroundColor: "#000"
|
||||
backgroundColor: "#000",
|
||||
}}
|
||||
preload="metadata"
|
||||
onError={(e) => {
|
||||
console.error('Video playback error:', e.currentTarget.error);
|
||||
}}
|
||||
onLoadStart={() => {
|
||||
console.log('Video loading started...');
|
||||
console.error(
|
||||
"Video playback error:",
|
||||
e.currentTarget.error,
|
||||
);
|
||||
}}
|
||||
onLoadedMetadata={(e) => {
|
||||
const video = e.currentTarget;
|
||||
console.log('Video metadata loaded, dimensions:', video.videoWidth, 'x', video.videoHeight);
|
||||
|
||||
// Get video dimensions and notify parent
|
||||
if (onMediaDimensionsChange && video.videoWidth && video.videoHeight) {
|
||||
if (
|
||||
onMediaDimensionsChange &&
|
||||
video.videoWidth &&
|
||||
video.videoHeight
|
||||
) {
|
||||
onMediaDimensionsChange({
|
||||
width: video.videoWidth,
|
||||
height: video.videoHeight
|
||||
height: video.videoHeight,
|
||||
});
|
||||
}
|
||||
}}
|
||||
onCanPlay={() => {
|
||||
console.log('Video can start playing');
|
||||
}}
|
||||
>
|
||||
<source src={videoUrl} type={mimeType} />
|
||||
<div className="text-center text-muted-foreground p-4">
|
||||
<AlertCircle className="w-8 h-8 mx-auto mb-2" />
|
||||
<p>Your browser does not support video playback for this format.</p>
|
||||
<p>
|
||||
Your browser does not support video playback for this
|
||||
format.
|
||||
</p>
|
||||
{onDownload && (
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -884,10 +896,8 @@ export function FileViewer({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Markdown file editor with live preview */}
|
||||
{fileTypeInfo.type === "markdown" && !showLargeFileWarning && (
|
||||
<div className="h-full flex flex-col">
|
||||
{/* Markdown toolbar */}
|
||||
<div className="flex-shrink-0 bg-muted/30 border-b border-border p-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -908,17 +918,13 @@ export function FileViewer({
|
||||
{t("fileManager.preview")}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Save button removed - using the main header save button instead */}
|
||||
</div>
|
||||
<div className="flex items-center gap-2"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Markdown content area */}
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
{markdownEditMode ? (
|
||||
<>
|
||||
{/* Editor pane */}
|
||||
<div className="flex-1 border-r border-border">
|
||||
<div className="h-full p-4 bg-background">
|
||||
<textarea
|
||||
@@ -933,14 +939,21 @@ export function FileViewer({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Preview pane */}
|
||||
<div className="flex-1 overflow-auto bg-muted/10">
|
||||
<div className="p-4">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
code({ node, inline, className, children, ...props }) {
|
||||
const match = /language-(\w+)/.exec(className || '');
|
||||
code({
|
||||
node,
|
||||
inline,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}) {
|
||||
const match = /language-(\w+)/.exec(
|
||||
className || "",
|
||||
);
|
||||
return !inline && match ? (
|
||||
<SyntaxHighlighter
|
||||
style={syntaxTheme}
|
||||
@@ -949,10 +962,13 @@ export function FileViewer({
|
||||
className="rounded-lg"
|
||||
{...props}
|
||||
>
|
||||
{String(children).replace(/\n$/, '')}
|
||||
{String(children).replace(/\n$/, "")}
|
||||
</SyntaxHighlighter>
|
||||
) : (
|
||||
<code className="bg-muted px-1 py-0.5 rounded text-sm font-mono" {...props}>
|
||||
<code
|
||||
className="bg-muted px-1 py-0.5 rounded text-sm font-mono"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
@@ -993,9 +1009,7 @@ export function FileViewer({
|
||||
</ol>
|
||||
),
|
||||
li: ({ children }) => (
|
||||
<li className="mb-1 text-foreground">
|
||||
{children}
|
||||
</li>
|
||||
<li className="mb-1 text-foreground">{children}</li>
|
||||
),
|
||||
blockquote: ({ children }) => (
|
||||
<blockquote className="border-l-4 border-blue-500 pl-3 mb-3 italic text-muted-foreground bg-muted/30 py-1">
|
||||
@@ -1010,15 +1024,9 @@ export function FileViewer({
|
||||
</div>
|
||||
),
|
||||
thead: ({ children }) => (
|
||||
<thead className="bg-muted">
|
||||
{children}
|
||||
</thead>
|
||||
),
|
||||
tbody: ({ children }) => (
|
||||
<tbody>
|
||||
{children}
|
||||
</tbody>
|
||||
<thead className="bg-muted">{children}</thead>
|
||||
),
|
||||
tbody: ({ children }) => <tbody>{children}</tbody>,
|
||||
tr: ({ children }) => (
|
||||
<tr className="border-b border-border">
|
||||
{children}
|
||||
@@ -1059,7 +1067,7 @@ export function FileViewer({
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
code({ node, inline, className, children, ...props }) {
|
||||
const match = /language-(\w+)/.exec(className || '');
|
||||
const match = /language-(\w+)/.exec(className || "");
|
||||
return !inline && match ? (
|
||||
<SyntaxHighlighter
|
||||
style={syntaxTheme}
|
||||
@@ -1068,10 +1076,13 @@ export function FileViewer({
|
||||
className="rounded-lg"
|
||||
{...props}
|
||||
>
|
||||
{String(children).replace(/\n$/, '')}
|
||||
{String(children).replace(/\n$/, "")}
|
||||
</SyntaxHighlighter>
|
||||
) : (
|
||||
<code className="bg-muted px-1 py-0.5 rounded text-sm font-mono" {...props}>
|
||||
<code
|
||||
className="bg-muted px-1 py-0.5 rounded text-sm font-mono"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
@@ -1112,9 +1123,7 @@ export function FileViewer({
|
||||
</ol>
|
||||
),
|
||||
li: ({ children }) => (
|
||||
<li className="mb-1 text-foreground">
|
||||
{children}
|
||||
</li>
|
||||
<li className="mb-1 text-foreground">{children}</li>
|
||||
),
|
||||
blockquote: ({ children }) => (
|
||||
<blockquote className="border-l-4 border-blue-500 pl-4 mb-4 italic text-muted-foreground bg-muted/30 py-2">
|
||||
@@ -1129,19 +1138,11 @@ export function FileViewer({
|
||||
</div>
|
||||
),
|
||||
thead: ({ children }) => (
|
||||
<thead className="bg-muted">
|
||||
{children}
|
||||
</thead>
|
||||
),
|
||||
tbody: ({ children }) => (
|
||||
<tbody>
|
||||
{children}
|
||||
</tbody>
|
||||
<thead className="bg-muted">{children}</thead>
|
||||
),
|
||||
tbody: ({ children }) => <tbody>{children}</tbody>,
|
||||
tr: ({ children }) => (
|
||||
<tr className="border-b border-border">
|
||||
{children}
|
||||
</tr>
|
||||
<tr className="border-b border-border">{children}</tr>
|
||||
),
|
||||
th: ({ children }) => (
|
||||
<th className="px-4 py-2 text-left font-semibold text-foreground">
|
||||
@@ -1174,10 +1175,8 @@ export function FileViewer({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* PDF file preview with react-pdf */}
|
||||
{fileTypeInfo.type === "pdf" && !showLargeFileWarning && (
|
||||
<div className="h-full flex flex-col bg-background">
|
||||
{/* PDF Controls */}
|
||||
<div className="flex-shrink-0 bg-muted/30 border-b border-border p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
@@ -1191,12 +1190,17 @@ export function FileViewer({
|
||||
{t("fileManager.previous")}
|
||||
</Button>
|
||||
<span className="text-sm text-foreground px-3 py-1 bg-background rounded border">
|
||||
{t("fileManager.pageXOfY", { current: pageNumber, total: numPages || 0 })}
|
||||
{t("fileManager.pageXOfY", {
|
||||
current: pageNumber,
|
||||
total: numPages || 0,
|
||||
})}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPageNumber(Math.min(numPages || 1, pageNumber + 1))}
|
||||
onClick={() =>
|
||||
setPageNumber(Math.min(numPages || 1, pageNumber + 1))
|
||||
}
|
||||
disabled={!numPages || pageNumber >= numPages}
|
||||
>
|
||||
{t("fileManager.next")}
|
||||
@@ -1236,13 +1240,14 @@ export function FileViewer({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* PDF Content */}
|
||||
<div className="flex-1 overflow-auto p-6 bg-gray-100 dark:bg-gray-900">
|
||||
<div className="flex justify-center">
|
||||
{pdfError ? (
|
||||
<div className="text-center text-muted-foreground p-8">
|
||||
<AlertCircle className="w-16 h-16 mx-auto mb-4 text-muted-foreground/50" />
|
||||
<h3 className="text-lg font-medium mb-2">Cannot load PDF</h3>
|
||||
<h3 className="text-lg font-medium mb-2">
|
||||
Cannot load PDF
|
||||
</h3>
|
||||
<p className="text-sm mb-4">
|
||||
There was an error loading this PDF file.
|
||||
</p>
|
||||
@@ -1264,22 +1269,23 @@ export function FileViewer({
|
||||
setNumPages(numPages);
|
||||
setPdfError(false);
|
||||
|
||||
// Notify parent about PDF dimensions for window sizing
|
||||
if (onMediaDimensionsChange) {
|
||||
onMediaDimensionsChange({
|
||||
width: 800,
|
||||
height: 600
|
||||
height: 600,
|
||||
});
|
||||
}
|
||||
}}
|
||||
onLoadError={(error) => {
|
||||
console.error('PDF load error:', error);
|
||||
console.error("PDF load error:", error);
|
||||
setPdfError(true);
|
||||
}}
|
||||
loading={
|
||||
<div className="text-center p-8">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500 mx-auto mb-2"></div>
|
||||
<p className="text-sm text-muted-foreground">Loading PDF...</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Loading PDF...
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
@@ -1290,7 +1296,9 @@ export function FileViewer({
|
||||
loading={
|
||||
<div className="text-center p-4">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-500 mx-auto mb-2"></div>
|
||||
<p className="text-xs text-muted-foreground">Loading page...</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Loading page...
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
@@ -1301,21 +1309,27 @@ export function FileViewer({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Audio file preview with react-h5-audio-player */}
|
||||
{fileTypeInfo.type === "audio" && !showLargeFileWarning && (
|
||||
<div className="p-6 flex items-center justify-center h-full">
|
||||
<div className="w-full max-w-2xl">
|
||||
{(() => {
|
||||
const ext = file.name.split('.').pop()?.toLowerCase() || '';
|
||||
const ext = file.name.split(".").pop()?.toLowerCase() || "";
|
||||
const mimeType = (() => {
|
||||
switch (ext) {
|
||||
case 'mp3': return 'audio/mpeg';
|
||||
case 'wav': return 'audio/wav';
|
||||
case 'flac': return 'audio/flac';
|
||||
case 'ogg': return 'audio/ogg';
|
||||
case 'aac': return 'audio/aac';
|
||||
case 'm4a': return 'audio/mp4';
|
||||
default: return 'audio/mpeg';
|
||||
case "mp3":
|
||||
return "audio/mpeg";
|
||||
case "wav":
|
||||
return "audio/wav";
|
||||
case "flac":
|
||||
return "audio/flac";
|
||||
case "ogg":
|
||||
return "audio/ogg";
|
||||
case "aac":
|
||||
return "audio/aac";
|
||||
case "m4a":
|
||||
return "audio/mp4";
|
||||
default:
|
||||
return "audio/mpeg";
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -1323,7 +1337,6 @@ export function FileViewer({
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Album artwork placeholder */}
|
||||
<div className="flex justify-center">
|
||||
<div
|
||||
className={cn(
|
||||
@@ -1335,7 +1348,6 @@ export function FileViewer({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Track info */}
|
||||
<div className="text-center">
|
||||
<h3 className="font-semibold text-foreground text-lg mb-1">
|
||||
{file.name.replace(/\.[^/.]+$/, "")}
|
||||
@@ -1345,30 +1357,20 @@ export function FileViewer({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Audio Player */}
|
||||
<div className="rounded-lg overflow-hidden">
|
||||
<AudioPlayer
|
||||
src={audioUrl}
|
||||
onPlay={() => {
|
||||
console.log('Audio playback started');
|
||||
}}
|
||||
onPause={() => {
|
||||
console.log('Audio playback paused');
|
||||
}}
|
||||
onLoadedMetadata={(e) => {
|
||||
const audio = e.currentTarget;
|
||||
console.log('Audio metadata loaded, duration:', audio.duration);
|
||||
|
||||
// Get audio dimensions for window sizing (use a standard audio player height)
|
||||
if (onMediaDimensionsChange) {
|
||||
onMediaDimensionsChange({
|
||||
width: 600,
|
||||
height: 400
|
||||
height: 400,
|
||||
});
|
||||
}
|
||||
}}
|
||||
onError={(e) => {
|
||||
console.error('Audio playback error:', e);
|
||||
console.error("Audio playback error:", e);
|
||||
}}
|
||||
showJumpControls={false}
|
||||
showSkipControls={false}
|
||||
@@ -1384,7 +1386,6 @@ export function FileViewer({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Unknown file type - only show when cannot display as text and no warning */}
|
||||
{fileTypeInfo.type === "unknown" &&
|
||||
!shouldShowAsText &&
|
||||
!showLargeFileWarning && (
|
||||
@@ -1413,7 +1414,6 @@ export function FileViewer({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Bottom status bar */}
|
||||
<div className="flex-shrink-0 bg-muted/50 border-t border-border px-4 py-2 text-xs text-muted-foreground">
|
||||
<div className="flex justify-between items-center">
|
||||
<span>{file.path}</span>
|
||||
|
||||
@@ -44,8 +44,7 @@ interface FileWindowProps {
|
||||
sshHost: SSHHost;
|
||||
initialX?: number;
|
||||
initialY?: number;
|
||||
onFileNotFound?: (file: FileItem) => void; // Callback for when file is not found
|
||||
// readOnly parameter removed, determined internally by FileViewer based on file type
|
||||
onFileNotFound?: (file: FileItem) => void;
|
||||
}
|
||||
|
||||
export function FileWindow({
|
||||
@@ -57,13 +56,8 @@ export function FileWindow({
|
||||
initialY = 100,
|
||||
onFileNotFound,
|
||||
}: FileWindowProps) {
|
||||
const {
|
||||
closeWindow,
|
||||
maximizeWindow,
|
||||
focusWindow,
|
||||
updateWindow,
|
||||
windows,
|
||||
} = useWindowManager();
|
||||
const { closeWindow, maximizeWindow, focusWindow, updateWindow, windows } =
|
||||
useWindowManager();
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -71,22 +65,18 @@ export function FileWindow({
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isEditable, setIsEditable] = useState(false);
|
||||
const [pendingContent, setPendingContent] = useState<string>("");
|
||||
const [mediaDimensions, setMediaDimensions] = useState<{ width: number; height: number } | undefined>();
|
||||
const [mediaDimensions, setMediaDimensions] = useState<
|
||||
{ width: number; height: number } | undefined
|
||||
>();
|
||||
const autoSaveTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const currentWindow = windows.find((w) => w.id === windowId);
|
||||
|
||||
// Ensure SSH connection is valid
|
||||
const ensureSSHConnection = async () => {
|
||||
try {
|
||||
// First check SSH connection status
|
||||
const status = await getSSHStatus(sshSessionId);
|
||||
console.log("SSH connection status:", status);
|
||||
|
||||
if (!status.connected) {
|
||||
console.log("SSH not connected, attempting to reconnect...");
|
||||
|
||||
// Re-establish connection
|
||||
await connectSSH(sshSessionId, {
|
||||
hostId: sshHost.id,
|
||||
ip: sshHost.ip,
|
||||
@@ -99,17 +89,13 @@ export function FileWindow({
|
||||
credentialId: sshHost.credentialId,
|
||||
userId: sshHost.userId,
|
||||
});
|
||||
|
||||
console.log("SSH reconnection successful");
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("SSH connection check/reconnect failed:", error);
|
||||
// Even if connection fails, try to continue and let specific API calls handle errors
|
||||
console.error("SSH connection check/reconnect failed:", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// Load file content
|
||||
useEffect(() => {
|
||||
const loadFileContent = async () => {
|
||||
if (file.type !== "file") return;
|
||||
@@ -117,23 +103,19 @@ export function FileWindow({
|
||||
try {
|
||||
setIsLoading(true);
|
||||
|
||||
// Ensure SSH connection is valid
|
||||
await ensureSSHConnection();
|
||||
|
||||
const response = await readSSHFile(sshSessionId, file.path);
|
||||
const fileContent = response.content || "";
|
||||
setContent(fileContent);
|
||||
setPendingContent(fileContent); // Initialize pending content
|
||||
setPendingContent(fileContent);
|
||||
|
||||
// If file size is unknown, calculate size based on content
|
||||
if (!file.size) {
|
||||
const contentSize = new Blob([fileContent]).size;
|
||||
file.size = contentSize;
|
||||
}
|
||||
|
||||
// Determine if editable based on file type: all except media files are editable
|
||||
const mediaExtensions = [
|
||||
// Image files
|
||||
"jpg",
|
||||
"jpeg",
|
||||
"png",
|
||||
@@ -143,7 +125,6 @@ export function FileWindow({
|
||||
"webp",
|
||||
"tiff",
|
||||
"ico",
|
||||
// Audio files
|
||||
"mp3",
|
||||
"wav",
|
||||
"ogg",
|
||||
@@ -151,7 +132,6 @@ export function FileWindow({
|
||||
"flac",
|
||||
"m4a",
|
||||
"wma",
|
||||
// Video files
|
||||
"mp4",
|
||||
"avi",
|
||||
"mov",
|
||||
@@ -160,7 +140,6 @@ export function FileWindow({
|
||||
"mkv",
|
||||
"webm",
|
||||
"m4v",
|
||||
// Archive files
|
||||
"zip",
|
||||
"rar",
|
||||
"7z",
|
||||
@@ -168,7 +147,6 @@ export function FileWindow({
|
||||
"gz",
|
||||
"bz2",
|
||||
"xz",
|
||||
// Binary files
|
||||
"exe",
|
||||
"dll",
|
||||
"so",
|
||||
@@ -178,28 +156,25 @@ export function FileWindow({
|
||||
];
|
||||
|
||||
const extension = file.name.split(".").pop()?.toLowerCase();
|
||||
// Only media files and binary files are not editable, all other files are editable
|
||||
setIsEditable(!mediaExtensions.includes(extension || ""));
|
||||
} catch (error: any) {
|
||||
console.error("Failed to load file:", error);
|
||||
|
||||
// Check if it's a large file error
|
||||
const errorData = error?.response?.data;
|
||||
if (errorData?.tooLarge) {
|
||||
toast.error(`File too large: ${errorData.error}`, {
|
||||
duration: 10000, // 10 seconds for important message
|
||||
duration: 10000,
|
||||
});
|
||||
} else if (
|
||||
error.message?.includes("connection") ||
|
||||
error.message?.includes("established")
|
||||
) {
|
||||
// If connection error, provide more specific error message
|
||||
toast.error(
|
||||
`SSH connection failed. Please check your connection to ${sshHost.name} (${sshHost.ip}:${sshHost.port})`,
|
||||
);
|
||||
} else {
|
||||
// Check if file not found (common error messages from cat command)
|
||||
const errorMessage = errorData?.error || error.message || "Unknown error";
|
||||
const errorMessage =
|
||||
errorData?.error || error.message || "Unknown error";
|
||||
const isFileNotFound =
|
||||
(error as any).isFileNotFound ||
|
||||
errorData?.fileNotFound ||
|
||||
@@ -211,19 +186,21 @@ export function FileWindow({
|
||||
errorMessage.includes("Resource not found");
|
||||
|
||||
if (isFileNotFound && onFileNotFound) {
|
||||
// Notify parent component about the missing file for cleanup
|
||||
onFileNotFound(file);
|
||||
toast.error(t("fileManager.fileNotFoundAndRemoved", { name: file.name }));
|
||||
toast.error(
|
||||
t("fileManager.fileNotFoundAndRemoved", { name: file.name }),
|
||||
);
|
||||
|
||||
// Close this window since the file doesn't exist
|
||||
closeWindow(windowId);
|
||||
return; // Exit early to prevent showing empty editor
|
||||
return;
|
||||
} else {
|
||||
toast.error(t("fileManager.failedToLoadFile", {
|
||||
error: errorMessage.includes("Server error occurred") ?
|
||||
t("fileManager.serverErrorOccurred") :
|
||||
errorMessage
|
||||
}));
|
||||
toast.error(
|
||||
t("fileManager.failedToLoadFile", {
|
||||
error: errorMessage.includes("Server error occurred")
|
||||
? t("fileManager.serverErrorOccurred")
|
||||
: errorMessage,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
@@ -234,19 +211,16 @@ export function FileWindow({
|
||||
loadFileContent();
|
||||
}, [file, sshSessionId, sshHost]);
|
||||
|
||||
// Save file
|
||||
const handleSave = async (newContent: string) => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
|
||||
// Ensure SSH connection is valid
|
||||
await ensureSSHConnection();
|
||||
|
||||
await writeSSHFile(sshSessionId, file.path, newContent);
|
||||
setContent(newContent);
|
||||
setPendingContent(""); // Clear pending content
|
||||
setPendingContent("");
|
||||
|
||||
// Clear auto-save timer
|
||||
if (autoSaveTimerRef.current) {
|
||||
clearTimeout(autoSaveTimerRef.current);
|
||||
autoSaveTimerRef.current = null;
|
||||
@@ -256,7 +230,6 @@ export function FileWindow({
|
||||
} catch (error: any) {
|
||||
console.error("Failed to save file:", error);
|
||||
|
||||
// If it's a connection error, provide more specific error message
|
||||
if (
|
||||
error.message?.includes("connection") ||
|
||||
error.message?.includes("established")
|
||||
@@ -265,36 +238,33 @@ export function FileWindow({
|
||||
`SSH connection failed. Please check your connection to ${sshHost.name} (${sshHost.ip}:${sshHost.port})`,
|
||||
);
|
||||
} else {
|
||||
toast.error(`${t("fileManager.failedToSaveFile")}: ${error.message || t("fileManager.unknownError")}`);
|
||||
toast.error(
|
||||
`${t("fileManager.failedToSaveFile")}: ${error.message || t("fileManager.unknownError")}`,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle content changes - set 1-minute auto-save
|
||||
const handleContentChange = (newContent: string) => {
|
||||
setPendingContent(newContent);
|
||||
|
||||
// Clear previous timer
|
||||
if (autoSaveTimerRef.current) {
|
||||
clearTimeout(autoSaveTimerRef.current);
|
||||
}
|
||||
|
||||
// Set new 1-minute auto-save timer
|
||||
autoSaveTimerRef.current = setTimeout(async () => {
|
||||
try {
|
||||
console.log("Auto-saving file...");
|
||||
await handleSave(newContent);
|
||||
toast.success(t("fileManager.fileAutoSaved"));
|
||||
} catch (error) {
|
||||
console.error("Auto-save failed:", error);
|
||||
toast.error(t("fileManager.autoSaveFailed"));
|
||||
}
|
||||
}, 60000); // 1 minute = 60000 milliseconds
|
||||
}, 60000);
|
||||
};
|
||||
|
||||
// Cleanup timer
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (autoSaveTimerRef.current) {
|
||||
@@ -303,16 +273,13 @@ export function FileWindow({
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Download file
|
||||
const handleDownload = async () => {
|
||||
try {
|
||||
// Ensure SSH connection is valid
|
||||
await ensureSSHConnection();
|
||||
|
||||
const response = await downloadSSHFile(sshSessionId, file.path);
|
||||
|
||||
if (response?.content) {
|
||||
// Convert base64 to blob and trigger download
|
||||
const byteCharacters = atob(response.content);
|
||||
const byteNumbers = new Array(byteCharacters.length);
|
||||
for (let i = 0; i < byteCharacters.length; i++) {
|
||||
@@ -337,7 +304,6 @@ export function FileWindow({
|
||||
} catch (error: any) {
|
||||
console.error("Failed to download file:", error);
|
||||
|
||||
// If it's a connection error, provide more specific error message
|
||||
if (
|
||||
error.message?.includes("connection") ||
|
||||
error.message?.includes("established")
|
||||
@@ -353,7 +319,6 @@ export function FileWindow({
|
||||
}
|
||||
};
|
||||
|
||||
// Window operation handling
|
||||
const handleClose = () => {
|
||||
closeWindow(windowId);
|
||||
};
|
||||
@@ -366,9 +331,10 @@ export function FileWindow({
|
||||
focusWindow(windowId);
|
||||
};
|
||||
|
||||
// Handle media dimensions change
|
||||
const handleMediaDimensionsChange = (dimensions: { width: number; height: number }) => {
|
||||
console.log('Media dimensions received:', dimensions);
|
||||
const handleMediaDimensionsChange = (dimensions: {
|
||||
width: number;
|
||||
height: number;
|
||||
}) => {
|
||||
setMediaDimensions(dimensions);
|
||||
};
|
||||
|
||||
@@ -397,7 +363,7 @@ export function FileWindow({
|
||||
content={pendingContent || content}
|
||||
savedContent={content}
|
||||
isLoading={isLoading}
|
||||
isEditable={isEditable} // Remove forced read-only mode, controlled internally by FileViewer
|
||||
isEditable={isEditable}
|
||||
onContentChange={handleContentChange}
|
||||
onSave={(newContent) => handleSave(newContent)}
|
||||
onDownload={handleDownload}
|
||||
|
||||
@@ -39,10 +39,8 @@ export function TerminalWindow({
|
||||
const { closeWindow, minimizeWindow, maximizeWindow, focusWindow, windows } =
|
||||
useWindowManager();
|
||||
|
||||
// Get current window state
|
||||
const currentWindow = windows.find((w) => w.id === windowId);
|
||||
if (!currentWindow) {
|
||||
console.warn(`Window with id ${windowId} not found`);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -65,7 +63,10 @@ export function TerminalWindow({
|
||||
const terminalTitle = executeCommand
|
||||
? t("terminal.runTitle", { host: hostConfig.name, command: executeCommand })
|
||||
: initialPath
|
||||
? t("terminal.terminalWithPath", { host: hostConfig.name, path: initialPath })
|
||||
? t("terminal.terminalWithPath", {
|
||||
host: hostConfig.name,
|
||||
path: initialPath,
|
||||
})
|
||||
: t("terminal.terminalTitle", { host: hostConfig.name });
|
||||
|
||||
return (
|
||||
|
||||
@@ -35,13 +35,11 @@ export function WindowManager({ children }: WindowManagerProps) {
|
||||
const nextZIndex = useRef(1000);
|
||||
const windowCounter = useRef(0);
|
||||
|
||||
// Open new window
|
||||
const openWindow = useCallback(
|
||||
(windowData: Omit<WindowInstance, "id" | "zIndex">) => {
|
||||
const id = `window-${++windowCounter.current}`;
|
||||
const zIndex = ++nextZIndex.current;
|
||||
|
||||
// Calculate offset position to avoid windows completely overlapping
|
||||
const offset = (windows.length % 5) * 30;
|
||||
const adjustedX = windowData.x + offset;
|
||||
const adjustedY = windowData.y + offset;
|
||||
@@ -60,12 +58,10 @@ export function WindowManager({ children }: WindowManagerProps) {
|
||||
[windows.length],
|
||||
);
|
||||
|
||||
// Close window
|
||||
const closeWindow = useCallback((id: string) => {
|
||||
setWindows((prev) => prev.filter((w) => w.id !== id));
|
||||
}, []);
|
||||
|
||||
// Minimize window
|
||||
const minimizeWindow = useCallback((id: string) => {
|
||||
setWindows((prev) =>
|
||||
prev.map((w) =>
|
||||
@@ -74,7 +70,6 @@ export function WindowManager({ children }: WindowManagerProps) {
|
||||
);
|
||||
}, []);
|
||||
|
||||
// Maximize/restore window
|
||||
const maximizeWindow = useCallback((id: string) => {
|
||||
setWindows((prev) =>
|
||||
prev.map((w) =>
|
||||
@@ -83,7 +78,6 @@ export function WindowManager({ children }: WindowManagerProps) {
|
||||
);
|
||||
}, []);
|
||||
|
||||
// Focus window (bring to top)
|
||||
const focusWindow = useCallback((id: string) => {
|
||||
setWindows((prev) => {
|
||||
const targetWindow = prev.find((w) => w.id === id);
|
||||
@@ -94,7 +88,6 @@ export function WindowManager({ children }: WindowManagerProps) {
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Update window properties
|
||||
const updateWindow = useCallback(
|
||||
(id: string, updates: Partial<WindowInstance>) => {
|
||||
setWindows((prev) =>
|
||||
@@ -117,7 +110,6 @@ export function WindowManager({ children }: WindowManagerProps) {
|
||||
return (
|
||||
<WindowManagerContext.Provider value={contextValue}>
|
||||
{children}
|
||||
{/* Render all windows */}
|
||||
<div className="window-container">
|
||||
{windows.map((window) => (
|
||||
<div key={window.id}>
|
||||
@@ -131,7 +123,6 @@ export function WindowManager({ children }: WindowManagerProps) {
|
||||
);
|
||||
}
|
||||
|
||||
// Hook for using window manager
|
||||
export function useWindowManager() {
|
||||
const context = React.useContext(WindowManagerContext);
|
||||
if (!context) {
|
||||
|
||||
@@ -450,30 +450,31 @@ export function HostManagerEditor({
|
||||
toast.success(t("hosts.hostAddedSuccessfully", { name: data.name }));
|
||||
}
|
||||
|
||||
// Handle AutoStart plaintext cache management
|
||||
if (savedHost && savedHost.id && data.tunnelConnections) {
|
||||
const hasAutoStartTunnels = data.tunnelConnections.some(tunnel => tunnel.autoStart);
|
||||
const hasAutoStartTunnels = data.tunnelConnections.some(
|
||||
(tunnel) => tunnel.autoStart,
|
||||
);
|
||||
|
||||
if (hasAutoStartTunnels) {
|
||||
// User has enabled autoStart on some tunnels
|
||||
// Need to ensure plaintext cache exists for this host
|
||||
try {
|
||||
await enableAutoStart(savedHost.id);
|
||||
console.log(`AutoStart plaintext cache enabled for SSH host ${savedHost.id}`);
|
||||
} catch (error) {
|
||||
console.warn(`Failed to enable AutoStart plaintext cache for SSH host ${savedHost.id}:`, error);
|
||||
// Don't fail the whole operation if cache setup fails
|
||||
toast.warning(t("hosts.autoStartEnableFailed", { name: data.name }));
|
||||
console.warn(
|
||||
`Failed to enable AutoStart plaintext cache for SSH host ${savedHost.id}:`,
|
||||
error,
|
||||
);
|
||||
toast.warning(
|
||||
t("hosts.autoStartEnableFailed", { name: data.name }),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// User has disabled autoStart on all tunnels
|
||||
// Clean up plaintext cache for this host
|
||||
try {
|
||||
await disableAutoStart(savedHost.id);
|
||||
console.log(`AutoStart plaintext cache disabled for SSH host ${savedHost.id}`);
|
||||
} catch (error) {
|
||||
console.warn(`Failed to disable AutoStart plaintext cache for SSH host ${savedHost.id}:`, error);
|
||||
// Don't fail the whole operation
|
||||
console.warn(
|
||||
`Failed to disable AutoStart plaintext cache for SSH host ${savedHost.id}:`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -990,7 +991,9 @@ export function HostManagerEditor({
|
||||
: ""
|
||||
}
|
||||
onChange={(value) => field.onChange(value)}
|
||||
placeholder={t("placeholders.pastePrivateKey")}
|
||||
placeholder={t(
|
||||
"placeholders.pastePrivateKey",
|
||||
)}
|
||||
theme={oneDark}
|
||||
className="border border-input rounded-md"
|
||||
minHeight="120px"
|
||||
|
||||
@@ -719,7 +719,7 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
|
||||
{
|
||||
sourcePort: 5432,
|
||||
endpointPort: 5432,
|
||||
endpointHost: t("interface.webServerProduction"),
|
||||
endpointHost: t("interface.webServerProduction"),
|
||||
maxRetries: 3,
|
||||
retryInterval: 10,
|
||||
autoStart: true,
|
||||
|
||||
@@ -180,7 +180,6 @@ export function Server({
|
||||
return (
|
||||
<div style={wrapperStyle} className={containerClass}>
|
||||
<div className="h-full w-full flex flex-col">
|
||||
{/* Top Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between px-4 pt-3 pb-3 gap-3">
|
||||
<div className="flex items-center gap-4 min-w-0">
|
||||
<div className="min-w-0">
|
||||
@@ -271,184 +270,183 @@ export function Server({
|
||||
</div>
|
||||
<Separator className="p-0.25 w-full" />
|
||||
|
||||
{/* Stats */}
|
||||
{showStatsUI && (
|
||||
<div className="rounded-lg border-2 border-dark-border m-3 bg-dark-bg-darker p-4">
|
||||
{isLoadingMetrics && !metrics ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-6 h-6 border-2 border-blue-400 border-t-transparent rounded-full animate-spin"></div>
|
||||
<span className="text-gray-300">
|
||||
{t("serverStats.loadingMetrics")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : !metrics && serverStatus === "offline" ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="text-center">
|
||||
<div className="w-12 h-12 mx-auto mb-3 rounded-full bg-red-500/20 flex items-center justify-center">
|
||||
<div className="w-6 h-6 border-2 border-red-400 rounded-full"></div>
|
||||
{isLoadingMetrics && !metrics ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-6 h-6 border-2 border-blue-400 border-t-transparent rounded-full animate-spin"></div>
|
||||
<span className="text-gray-300">
|
||||
{t("serverStats.loadingMetrics")}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-gray-300 mb-1">
|
||||
{t("serverStats.serverOffline")}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
{t("serverStats.cannotFetchMetrics")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4 lg:gap-6">
|
||||
{/* CPU Stats */}
|
||||
<div className="space-y-3 p-4 rounded-lg bg-dark-bg/50 border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Cpu className="h-5 w-5 text-blue-400" />
|
||||
<h3 className="font-semibold text-lg text-white">
|
||||
{t("serverStats.cpuUsage")}
|
||||
</h3>
|
||||
) : !metrics && serverStatus === "offline" ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="text-center">
|
||||
<div className="w-12 h-12 mx-auto mb-3 rounded-full bg-red-500/20 flex items-center justify-center">
|
||||
<div className="w-6 h-6 border-2 border-red-400 rounded-full"></div>
|
||||
</div>
|
||||
<p className="text-gray-300 mb-1">
|
||||
{t("serverStats.serverOffline")}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
{t("serverStats.cannotFetchMetrics")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4 lg:gap-6">
|
||||
{/* CPU Stats */}
|
||||
<div className="space-y-3 p-4 rounded-lg bg-dark-bg/50 border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Cpu className="h-5 w-5 text-blue-400" />
|
||||
<h3 className="font-semibold text-lg text-white">
|
||||
{t("serverStats.cpuUsage")}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-gray-300">
|
||||
{(() => {
|
||||
const pct = metrics?.cpu?.percent;
|
||||
const cores = metrics?.cpu?.cores;
|
||||
const pctText =
|
||||
typeof pct === "number" ? `${pct}%` : "N/A";
|
||||
const coresText =
|
||||
typeof cores === "number"
|
||||
? t("serverStats.cpuCores", { count: cores })
|
||||
: t("serverStats.naCpus");
|
||||
return `${pctText} ${t("serverStats.of")} ${coresText}`;
|
||||
})()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<Progress
|
||||
value={
|
||||
typeof metrics?.cpu?.percent === "number"
|
||||
? metrics!.cpu!.percent!
|
||||
: 0
|
||||
}
|
||||
className="h-2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-gray-500">
|
||||
{metrics?.cpu?.load
|
||||
? `Load: ${metrics.cpu.load[0].toFixed(2)}, ${metrics.cpu.load[1].toFixed(2)}, ${metrics.cpu.load[2].toFixed(2)}`
|
||||
: "Load: N/A"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-gray-300">
|
||||
{/* Memory Stats */}
|
||||
<div className="space-y-3 p-4 rounded-lg bg-dark-bg/50 border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<MemoryStick className="h-5 w-5 text-green-400" />
|
||||
<h3 className="font-semibold text-lg text-white">
|
||||
{t("serverStats.memoryUsage")}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-gray-300">
|
||||
{(() => {
|
||||
const pct = metrics?.memory?.percent;
|
||||
const used = metrics?.memory?.usedGiB;
|
||||
const total = metrics?.memory?.totalGiB;
|
||||
const pctText =
|
||||
typeof pct === "number" ? `${pct}%` : "N/A";
|
||||
const usedText =
|
||||
typeof used === "number"
|
||||
? `${used.toFixed(1)} GiB`
|
||||
: "N/A";
|
||||
const totalText =
|
||||
typeof total === "number"
|
||||
? `${total.toFixed(1)} GiB`
|
||||
: "N/A";
|
||||
return `${pctText} (${usedText} ${t("serverStats.of")} ${totalText})`;
|
||||
})()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<Progress
|
||||
value={
|
||||
typeof metrics?.memory?.percent === "number"
|
||||
? metrics!.memory!.percent!
|
||||
: 0
|
||||
}
|
||||
className="h-2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-gray-500">
|
||||
{(() => {
|
||||
const pct = metrics?.cpu?.percent;
|
||||
const cores = metrics?.cpu?.cores;
|
||||
const pctText =
|
||||
typeof pct === "number" ? `${pct}%` : "N/A";
|
||||
const coresText =
|
||||
typeof cores === "number"
|
||||
? t("serverStats.cpuCores", { count: cores })
|
||||
: t("serverStats.naCpus");
|
||||
return `${pctText} ${t("serverStats.of")} ${coresText}`;
|
||||
})()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<Progress
|
||||
value={
|
||||
typeof metrics?.cpu?.percent === "number"
|
||||
? metrics!.cpu!.percent!
|
||||
: 0
|
||||
}
|
||||
className="h-2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-gray-500">
|
||||
{metrics?.cpu?.load
|
||||
? `Load: ${metrics.cpu.load[0].toFixed(2)}, ${metrics.cpu.load[1].toFixed(2)}, ${metrics.cpu.load[2].toFixed(2)}`
|
||||
: "Load: N/A"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Memory Stats */}
|
||||
<div className="space-y-3 p-4 rounded-lg bg-dark-bg/50 border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<MemoryStick className="h-5 w-5 text-green-400" />
|
||||
<h3 className="font-semibold text-lg text-white">
|
||||
{t("serverStats.memoryUsage")}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-gray-300">
|
||||
{(() => {
|
||||
const pct = metrics?.memory?.percent;
|
||||
const used = metrics?.memory?.usedGiB;
|
||||
const total = metrics?.memory?.totalGiB;
|
||||
const pctText =
|
||||
typeof pct === "number" ? `${pct}%` : "N/A";
|
||||
const usedText =
|
||||
typeof used === "number"
|
||||
? `${used.toFixed(1)} GiB`
|
||||
const free =
|
||||
typeof used === "number" && typeof total === "number"
|
||||
? (total - used).toFixed(1)
|
||||
: "N/A";
|
||||
const totalText =
|
||||
typeof total === "number"
|
||||
? `${total.toFixed(1)} GiB`
|
||||
: "N/A";
|
||||
return `${pctText} (${usedText} ${t("serverStats.of")} ${totalText})`;
|
||||
return `Free: ${free} GiB`;
|
||||
})()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<Progress
|
||||
value={
|
||||
typeof metrics?.memory?.percent === "number"
|
||||
? metrics!.memory!.percent!
|
||||
: 0
|
||||
}
|
||||
className="h-2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-gray-500">
|
||||
{(() => {
|
||||
const used = metrics?.memory?.usedGiB;
|
||||
const total = metrics?.memory?.totalGiB;
|
||||
const free =
|
||||
typeof used === "number" && typeof total === "number"
|
||||
? (total - used).toFixed(1)
|
||||
: "N/A";
|
||||
return `Free: ${free} GiB`;
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Disk Stats */}
|
||||
<div className="space-y-3 p-4 rounded-lg bg-dark-bg/50 border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<HardDrive className="h-5 w-5 text-orange-400" />
|
||||
<h3 className="font-semibold text-lg text-white">
|
||||
{t("serverStats.rootStorageSpace")}
|
||||
</h3>
|
||||
</div>
|
||||
{/* Disk Stats */}
|
||||
<div className="space-y-3 p-4 rounded-lg bg-dark-bg/50 border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<HardDrive className="h-5 w-5 text-orange-400" />
|
||||
<h3 className="font-semibold text-lg text-white">
|
||||
{t("serverStats.rootStorageSpace")}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-gray-300">
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-gray-300">
|
||||
{(() => {
|
||||
const pct = metrics?.disk?.percent;
|
||||
const used = metrics?.disk?.usedHuman;
|
||||
const total = metrics?.disk?.totalHuman;
|
||||
const pctText =
|
||||
typeof pct === "number" ? `${pct}%` : "N/A";
|
||||
const usedText = used ?? "N/A";
|
||||
const totalText = total ?? "N/A";
|
||||
return `${pctText} (${usedText} ${t("serverStats.of")} ${totalText})`;
|
||||
})()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<Progress
|
||||
value={
|
||||
typeof metrics?.disk?.percent === "number"
|
||||
? metrics!.disk!.percent!
|
||||
: 0
|
||||
}
|
||||
className="h-2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-gray-500">
|
||||
{(() => {
|
||||
const pct = metrics?.disk?.percent;
|
||||
const used = metrics?.disk?.usedHuman;
|
||||
const total = metrics?.disk?.totalHuman;
|
||||
const pctText =
|
||||
typeof pct === "number" ? `${pct}%` : "N/A";
|
||||
const usedText = used ?? "N/A";
|
||||
const totalText = total ?? "N/A";
|
||||
return `${pctText} (${usedText} ${t("serverStats.of")} ${totalText})`;
|
||||
return used && total
|
||||
? `Available: ${total}`
|
||||
: "Available: N/A";
|
||||
})()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<Progress
|
||||
value={
|
||||
typeof metrics?.disk?.percent === "number"
|
||||
? metrics!.disk!.percent!
|
||||
: 0
|
||||
}
|
||||
className="h-2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-gray-500">
|
||||
{(() => {
|
||||
const used = metrics?.disk?.usedHuman;
|
||||
const total = metrics?.disk?.totalHuman;
|
||||
return used && total
|
||||
? `Available: ${total}`
|
||||
: "Available: N/A";
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -36,18 +36,9 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
// DEBUG: Add global JWT test function (only once)
|
||||
if (typeof window !== 'undefined' && !(window as any).testJWT) {
|
||||
if (typeof window !== "undefined" && !(window as any).testJWT) {
|
||||
(window as any).testJWT = () => {
|
||||
const jwt = getCookie("jwt");
|
||||
console.log("Manual JWT Test:", {
|
||||
isElectron: isElectron(),
|
||||
rawCookie: document.cookie,
|
||||
localStorage: localStorage.getItem("jwt"),
|
||||
getCookieResult: jwt,
|
||||
jwtLength: jwt?.length || 0,
|
||||
jwtFirst20: jwt?.substring(0, 20) || "empty"
|
||||
});
|
||||
return jwt;
|
||||
};
|
||||
}
|
||||
@@ -83,35 +74,25 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
||||
isVisibleRef.current = isVisible;
|
||||
}, [isVisible]);
|
||||
|
||||
// Monitor authentication state - Linus principle: explicit state management
|
||||
useEffect(() => {
|
||||
const checkAuth = () => {
|
||||
const jwtToken = getCookie("jwt");
|
||||
const isAuth = !!(jwtToken && jwtToken.trim() !== "");
|
||||
|
||||
// Only update state if it actually changed - prevent unnecessary re-renders
|
||||
setIsAuthenticated(prev => {
|
||||
setIsAuthenticated((prev) => {
|
||||
if (prev !== isAuth) {
|
||||
console.debug("Auth State Changed:", {
|
||||
from: prev,
|
||||
to: isAuth,
|
||||
jwtPresent: !!jwtToken,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
return isAuth;
|
||||
}
|
||||
return prev; // No change, don't trigger re-render
|
||||
return prev;
|
||||
});
|
||||
};
|
||||
|
||||
// Check immediately
|
||||
checkAuth();
|
||||
|
||||
// Reduced frequency - check every 5 seconds instead of every second
|
||||
const authCheckInterval = setInterval(checkAuth, 5000);
|
||||
|
||||
return () => clearInterval(authCheckInterval);
|
||||
}, []); // No dependencies - prevent infinite loop
|
||||
}, []);
|
||||
|
||||
function hardRefresh() {
|
||||
try {
|
||||
@@ -187,8 +168,6 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
||||
[terminal],
|
||||
);
|
||||
|
||||
// Resize handling moved to AppView to avoid conflicts - Linus principle: eliminate duplicate complexity
|
||||
|
||||
function handleWindowResize() {
|
||||
if (!isVisibleRef.current) return;
|
||||
fitAddonRef.current?.fit();
|
||||
@@ -207,7 +186,6 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
||||
isReconnectingRef.current ||
|
||||
isConnectingRef.current
|
||||
) {
|
||||
console.debug("Skipping reconnection - already in progress or blocked");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -245,7 +223,6 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify authentication before attempting reconnection
|
||||
const jwtToken = getCookie("jwt");
|
||||
if (!jwtToken || jwtToken.trim() === "") {
|
||||
console.warn("Reconnection cancelled - no authentication token");
|
||||
@@ -266,9 +243,7 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
||||
}
|
||||
|
||||
function connectToHost(cols: number, rows: number) {
|
||||
// Prevent duplicate connections - Linus principle: fail fast
|
||||
if (isConnectingRef.current) {
|
||||
console.debug("Skipping connection - already connecting");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -280,26 +255,14 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
||||
window.location.port === "5173" ||
|
||||
window.location.port === "");
|
||||
|
||||
// Get JWT token for WebSocket authentication (from cookie, not localStorage)
|
||||
const jwtToken = getCookie("jwt");
|
||||
|
||||
// DEBUG: Log authentication issues only
|
||||
if (!jwtToken || jwtToken.trim() === "") {
|
||||
console.debug("JWT Debug Info:", {
|
||||
isElectron: isElectron(),
|
||||
rawCookie: isElectron() ? localStorage.getItem("jwt") : document.cookie,
|
||||
jwtToken: jwtToken,
|
||||
isEmpty: true
|
||||
});
|
||||
}
|
||||
|
||||
if (!jwtToken || jwtToken.trim() === "") {
|
||||
console.error("No JWT token available for WebSocket connection");
|
||||
setIsConnected(false);
|
||||
setIsConnecting(false);
|
||||
setConnectionError("Authentication required");
|
||||
isConnectingRef.current = false; // Reset on auth failure
|
||||
// Don't show toast here - let auth system handle it
|
||||
isConnectingRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -317,13 +280,13 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
||||
})()
|
||||
: `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}/ssh/websocket/`;
|
||||
|
||||
// Clean up existing connection to prevent duplicates - Linus principle: eliminate complexity
|
||||
if (webSocketRef.current && webSocketRef.current.readyState !== WebSocket.CLOSED) {
|
||||
console.log("Closing existing WebSocket connection before creating new one");
|
||||
if (
|
||||
webSocketRef.current &&
|
||||
webSocketRef.current.readyState !== WebSocket.CLOSED
|
||||
) {
|
||||
webSocketRef.current.close();
|
||||
}
|
||||
|
||||
// Clear existing intervals/timeouts
|
||||
if (pingIntervalRef.current) {
|
||||
clearInterval(pingIntervalRef.current);
|
||||
pingIntervalRef.current = null;
|
||||
@@ -333,18 +296,8 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
||||
connectionTimeoutRef.current = null;
|
||||
}
|
||||
|
||||
// Add JWT token as query parameter for authentication
|
||||
const wsUrl = `${baseWsUrl}?token=${encodeURIComponent(jwtToken)}`;
|
||||
|
||||
// DEBUG: Log WebSocket connection details
|
||||
console.log("Creating WebSocket connection:", {
|
||||
baseWsUrl,
|
||||
jwtTokenLength: jwtToken.length,
|
||||
jwtTokenStart: jwtToken.substring(0, 20),
|
||||
encodedTokenLength: encodeURIComponent(jwtToken).length,
|
||||
wsUrl: wsUrl.length > 100 ? `${wsUrl.substring(0, 100)}...` : wsUrl
|
||||
});
|
||||
|
||||
const ws = new WebSocket(wsUrl);
|
||||
webSocketRef.current = ws;
|
||||
wasDisconnectedBySSH.current = false;
|
||||
@@ -439,7 +392,7 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
||||
} else if (msg.type === "connected") {
|
||||
setIsConnected(true);
|
||||
setIsConnecting(false);
|
||||
isConnectingRef.current = false; // Clear connecting state
|
||||
isConnectingRef.current = false;
|
||||
if (connectionTimeoutRef.current) {
|
||||
clearTimeout(connectionTimeoutRef.current);
|
||||
connectionTimeoutRef.current = null;
|
||||
@@ -467,25 +420,21 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
||||
|
||||
ws.addEventListener("close", (event) => {
|
||||
setIsConnected(false);
|
||||
isConnectingRef.current = false; // Clear connecting state
|
||||
isConnectingRef.current = false;
|
||||
if (terminal) {
|
||||
terminal.clear();
|
||||
}
|
||||
|
||||
// Handle authentication errors (code 1008)
|
||||
if (event.code === 1008) {
|
||||
console.error("WebSocket authentication failed:", event.reason);
|
||||
setConnectionError("Authentication failed - please re-login");
|
||||
setIsConnecting(false);
|
||||
shouldNotReconnectRef.current = true;
|
||||
|
||||
// Clear invalid JWT token
|
||||
localStorage.removeItem("jwt");
|
||||
|
||||
// Show authentication error message
|
||||
toast.error("Authentication failed. Please log in again.");
|
||||
|
||||
// Don't attempt to reconnect on auth failure
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -501,7 +450,7 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
||||
|
||||
ws.addEventListener("error", (event) => {
|
||||
setIsConnected(false);
|
||||
isConnectingRef.current = false; // Clear connecting state
|
||||
isConnectingRef.current = false;
|
||||
setConnectionError(t("terminal.websocketError"));
|
||||
if (terminal) {
|
||||
terminal.clear();
|
||||
@@ -546,12 +495,6 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
||||
useEffect(() => {
|
||||
if (!terminal || !xtermRef.current || !hostConfig) return;
|
||||
|
||||
// Critical auth check - prevent terminal setup without authentication - Linus principle: fail fast
|
||||
if (!isAuthenticated) {
|
||||
console.debug("Terminal setup delayed - waiting for authentication");
|
||||
return;
|
||||
}
|
||||
|
||||
terminal.options = {
|
||||
cursorBlink: true,
|
||||
cursorStyle: "bar",
|
||||
@@ -563,7 +506,6 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
||||
allowTransparency: true,
|
||||
convertEol: true,
|
||||
windowsMode: false,
|
||||
// Keep Option key for special characters on macOS (false = allows special chars, true = Meta key)
|
||||
macOptionIsMeta: false,
|
||||
macOptionClickForcesSelection: false,
|
||||
rightClickSelectsWord: false,
|
||||
@@ -604,32 +546,26 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
||||
};
|
||||
element?.addEventListener("contextmenu", handleContextMenu);
|
||||
|
||||
// Add macOS-specific keyboard event handling for special characters
|
||||
const handleMacKeyboard = (e: KeyboardEvent) => {
|
||||
// Detect macOS
|
||||
const isMacOS =
|
||||
navigator.platform.toUpperCase().indexOf("MAC") >= 0 ||
|
||||
navigator.userAgent.toUpperCase().indexOf("MAC") >= 0;
|
||||
|
||||
if (!isMacOS) return;
|
||||
|
||||
// Handle Option key combinations for special characters
|
||||
if (e.altKey && !e.metaKey && !e.ctrlKey) {
|
||||
// Use both e.key and e.code to handle different keyboard layouts
|
||||
const keyMappings: { [key: string]: string } = {
|
||||
// Using e.key values
|
||||
"7": "|", // Option+7 = pipe symbol
|
||||
"2": "€", // Option+2 = euro symbol
|
||||
"8": "[", // Option+8 = left bracket
|
||||
"9": "]", // Option+9 = right bracket
|
||||
l: "@", // Option+L = at symbol
|
||||
L: "@", // Option+L = at symbol (uppercase)
|
||||
// Using e.code values as fallback
|
||||
Digit7: "|", // Option+7 = pipe symbol
|
||||
Digit2: "€", // Option+2 = euro symbol
|
||||
Digit8: "[", // Option+8 = left bracket
|
||||
Digit9: "]", // Option+9 = right bracket
|
||||
KeyL: "@", // Option+L = at symbol
|
||||
"7": "|",
|
||||
"2": "€",
|
||||
"8": "[",
|
||||
"9": "]",
|
||||
l: "@",
|
||||
L: "@",
|
||||
Digit7: "|",
|
||||
Digit2: "€",
|
||||
Digit8: "[",
|
||||
Digit9: "]",
|
||||
KeyL: "@",
|
||||
};
|
||||
|
||||
const char = keyMappings[e.key] || keyMappings[e.code];
|
||||
@@ -637,7 +573,6 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
// Send the character directly to the terminal
|
||||
if (webSocketRef.current?.readyState === 1) {
|
||||
webSocketRef.current.send(
|
||||
JSON.stringify({ type: "input", data: char }),
|
||||
@@ -657,12 +592,11 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
||||
fitAddonRef.current?.fit();
|
||||
if (terminal) scheduleNotify(terminal.cols, terminal.rows);
|
||||
hardRefresh();
|
||||
}, 150); // Increased debounce for better stability
|
||||
}, 150);
|
||||
});
|
||||
|
||||
resizeObserver.observe(xtermRef.current);
|
||||
|
||||
// Show terminal immediately - better UX, no unnecessary delays
|
||||
setVisible(true);
|
||||
|
||||
const readyFonts =
|
||||
@@ -671,7 +605,6 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
||||
: Promise.resolve();
|
||||
|
||||
readyFonts.then(() => {
|
||||
// Fixed delay and authentication check - Linus principle: eliminate race conditions
|
||||
setTimeout(() => {
|
||||
fitAddon.fit();
|
||||
if (terminal) scheduleNotify(terminal.cols, terminal.rows);
|
||||
@@ -681,23 +614,15 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
||||
terminal.focus();
|
||||
}
|
||||
|
||||
// Verify authentication before attempting WebSocket connection
|
||||
const jwtToken = getCookie("jwt");
|
||||
|
||||
// DEBUG: Log only authentication failures
|
||||
if (!jwtToken || jwtToken.trim() === "") {
|
||||
console.debug("ReadyFonts Auth Check Failed:", {
|
||||
isAuthenticated: isAuthenticated,
|
||||
jwtPresent: !!jwtToken
|
||||
});
|
||||
}
|
||||
|
||||
if (!jwtToken || jwtToken.trim() === "") {
|
||||
console.warn("WebSocket connection delayed - no authentication token");
|
||||
console.warn(
|
||||
"WebSocket connection delayed - no authentication token",
|
||||
);
|
||||
setIsConnected(false);
|
||||
setIsConnecting(false);
|
||||
setConnectionError("Authentication required");
|
||||
// Don't show toast here - let auth system handle it
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -705,7 +630,7 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
||||
const rows = terminal.rows;
|
||||
|
||||
connectToHost(cols, rows);
|
||||
}, 200); // Increased from 100ms to 200ms for auth stability
|
||||
}, 200);
|
||||
});
|
||||
|
||||
return () => {
|
||||
@@ -728,7 +653,7 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
||||
}
|
||||
webSocketRef.current?.close();
|
||||
};
|
||||
}, [xtermRef, terminal, hostConfig]); // Removed isAuthenticated to prevent infinite loop
|
||||
}, [xtermRef, terminal, hostConfig]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isVisible && fitAddonRef.current) {
|
||||
|
||||
@@ -27,18 +27,14 @@ function AppContent() {
|
||||
|
||||
useEffect(() => {
|
||||
const checkAuth = () => {
|
||||
// With HttpOnly cookies, we can't check for JWT presence from frontend
|
||||
// Instead, we'll try to get user info and handle the response
|
||||
setAuthLoading(true);
|
||||
getUserInfo()
|
||||
.then((meRes) => {
|
||||
setIsAuthenticated(true);
|
||||
setIsAdmin(!!meRes.is_admin);
|
||||
setUsername(meRes.username || null);
|
||||
|
||||
// Check if user data is unlocked
|
||||
|
||||
if (!meRes.data_unlocked) {
|
||||
// Data is locked - user needs to re-authenticate
|
||||
console.warn("User data is locked - re-authentication required");
|
||||
setIsAuthenticated(false);
|
||||
setIsAdmin(false);
|
||||
@@ -49,8 +45,7 @@ function AppContent() {
|
||||
setIsAuthenticated(false);
|
||||
setIsAdmin(false);
|
||||
setUsername(null);
|
||||
|
||||
// Check if this is a session expiration error
|
||||
|
||||
const errorCode = err?.response?.data?.code;
|
||||
if (errorCode === "SESSION_EXPIRED") {
|
||||
console.warn("Session expired - please log in again");
|
||||
|
||||
@@ -228,7 +228,9 @@ export function ServerConfig({
|
||||
{versionInfo && !versionDismissed && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium">{t("versionCheck.checkUpdates")}</h3>
|
||||
<h3 className="text-sm font-medium">
|
||||
{t("versionCheck.checkUpdates")}
|
||||
</h3>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
|
||||
@@ -50,12 +50,10 @@ export function Homepage({
|
||||
setDbError(null);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("Homepage: Error fetching user info:", err);
|
||||
setIsAdmin(false);
|
||||
setUsername(null);
|
||||
setUserId(null);
|
||||
|
||||
// Check if this is a session expiration error
|
||||
|
||||
const errorCode = err?.response?.data?.code;
|
||||
if (errorCode === "SESSION_EXPIRED") {
|
||||
console.warn("Session expired - please log in again");
|
||||
@@ -158,7 +156,7 @@ export function Homepage({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
<HomepageAlertManager userId={userId} loggedIn={loggedIn} />
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -198,10 +198,6 @@ export function HomepageAuth({
|
||||
throw new Error(t("errors.loginFailed"));
|
||||
}
|
||||
|
||||
// JWT token is now automatically set as HttpOnly cookie by backend
|
||||
// No need to manually manage the token on frontend
|
||||
console.log("Login successful - JWT set as secure HttpOnly cookie");
|
||||
|
||||
[meRes] = await Promise.all([getUserInfo()]);
|
||||
|
||||
setInternalLoggedIn(true);
|
||||
@@ -245,10 +241,6 @@ export function HomepageAuth({
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
// ===== Legacy password reset functions (deprecated) =====
|
||||
|
||||
async function handleInitiatePasswordReset() {
|
||||
@@ -312,12 +304,14 @@ export function HomepageAuth({
|
||||
|
||||
setResetSuccess(true);
|
||||
toast.success(t("messages.passwordResetSuccess"));
|
||||
|
||||
|
||||
// Immediately redirect to login after successful reset
|
||||
setTab("login");
|
||||
resetPasswordState();
|
||||
} catch (err: any) {
|
||||
toast.error(err?.response?.data?.error || t("errors.failedCompleteReset"));
|
||||
toast.error(
|
||||
err?.response?.data?.error || t("errors.failedCompleteReset"),
|
||||
);
|
||||
} finally {
|
||||
setResetLoading(false);
|
||||
}
|
||||
@@ -630,9 +624,7 @@ export function HomepageAuth({
|
||||
{isElectron() && currentServerUrl && (
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label className="text-sm text-muted-foreground">
|
||||
Server
|
||||
</Label>
|
||||
<Label className="text-sm text-muted-foreground">Server</Label>
|
||||
<div className="text-xs text-muted-foreground truncate max-w-[200px]">
|
||||
{currentServerUrl}
|
||||
</div>
|
||||
@@ -711,373 +703,364 @@ export function HomepageAuth({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!internalLoggedIn &&
|
||||
!authLoading &&
|
||||
!totpRequired && (
|
||||
<>
|
||||
<div className="flex gap-2 mb-6">
|
||||
{!internalLoggedIn && !authLoading && !totpRequired && (
|
||||
<>
|
||||
<div className="flex gap-2 mb-6">
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex-1 py-2 text-base font-medium rounded-md transition-all",
|
||||
tab === "login"
|
||||
? "bg-primary text-primary-foreground shadow"
|
||||
: "bg-muted text-muted-foreground hover:bg-accent",
|
||||
)}
|
||||
onClick={() => {
|
||||
setTab("login");
|
||||
if (tab === "reset") resetPasswordState();
|
||||
if (tab === "signup") clearFormFields();
|
||||
}}
|
||||
aria-selected={tab === "login"}
|
||||
disabled={loading || firstUser}
|
||||
>
|
||||
{t("common.login")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex-1 py-2 text-base font-medium rounded-md transition-all",
|
||||
tab === "signup"
|
||||
? "bg-primary text-primary-foreground shadow"
|
||||
: "bg-muted text-muted-foreground hover:bg-accent",
|
||||
)}
|
||||
onClick={() => {
|
||||
setTab("signup");
|
||||
if (tab === "reset") resetPasswordState();
|
||||
if (tab === "login") clearFormFields();
|
||||
}}
|
||||
aria-selected={tab === "signup"}
|
||||
disabled={loading || !registrationAllowed}
|
||||
>
|
||||
{t("common.register")}
|
||||
</button>
|
||||
{oidcConfigured && (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex-1 py-2 text-base font-medium rounded-md transition-all",
|
||||
tab === "login"
|
||||
tab === "external"
|
||||
? "bg-primary text-primary-foreground shadow"
|
||||
: "bg-muted text-muted-foreground hover:bg-accent",
|
||||
)}
|
||||
onClick={() => {
|
||||
setTab("login");
|
||||
setTab("external");
|
||||
if (tab === "reset") resetPasswordState();
|
||||
if (tab === "signup") clearFormFields();
|
||||
if (tab === "login" || tab === "signup") clearFormFields();
|
||||
}}
|
||||
aria-selected={tab === "login"}
|
||||
disabled={loading || firstUser}
|
||||
aria-selected={tab === "external"}
|
||||
disabled={oidcLoading}
|
||||
>
|
||||
{t("common.login")}
|
||||
{t("auth.external")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex-1 py-2 text-base font-medium rounded-md transition-all",
|
||||
tab === "signup"
|
||||
? "bg-primary text-primary-foreground shadow"
|
||||
: "bg-muted text-muted-foreground hover:bg-accent",
|
||||
)}
|
||||
onClick={() => {
|
||||
setTab("signup");
|
||||
if (tab === "reset") resetPasswordState();
|
||||
if (tab === "login") clearFormFields();
|
||||
}}
|
||||
aria-selected={tab === "signup"}
|
||||
disabled={loading || !registrationAllowed}
|
||||
>
|
||||
{t("common.register")}
|
||||
</button>
|
||||
{oidcConfigured && (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex-1 py-2 text-base font-medium rounded-md transition-all",
|
||||
tab === "external"
|
||||
? "bg-primary text-primary-foreground shadow"
|
||||
: "bg-muted text-muted-foreground hover:bg-accent",
|
||||
)}
|
||||
</div>
|
||||
<div className="mb-6 text-center">
|
||||
<h2 className="text-xl font-bold mb-1">
|
||||
{tab === "login"
|
||||
? t("auth.loginTitle")
|
||||
: tab === "signup"
|
||||
? t("auth.registerTitle")
|
||||
: tab === "external"
|
||||
? t("auth.loginWithExternal")
|
||||
: t("auth.forgotPassword")}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{tab === "external" || tab === "reset" ? (
|
||||
<div className="flex flex-col gap-5">
|
||||
{tab === "external" && (
|
||||
<>
|
||||
<div className="text-center text-muted-foreground mb-4">
|
||||
<p>{t("auth.loginWithExternalDesc")}</p>
|
||||
</div>
|
||||
{(() => {
|
||||
if (isElectron()) {
|
||||
return (
|
||||
<div className="text-center p-4 bg-muted/50 rounded-lg border">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("auth.externalNotSupportedInElectron")}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full h-11 mt-2 text-base font-semibold"
|
||||
disabled={oidcLoading}
|
||||
onClick={handleOIDCLogin}
|
||||
>
|
||||
{oidcLoading ? Spinner : t("auth.loginWithExternal")}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
})()}
|
||||
</>
|
||||
)}
|
||||
{tab === "reset" && (
|
||||
<>
|
||||
{resetStep === "initiate" && (
|
||||
<>
|
||||
<div className="text-center text-muted-foreground mb-4">
|
||||
<p>{t("auth.resetCodeDesc")}</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="reset-username">
|
||||
{t("common.username")}
|
||||
</Label>
|
||||
<Input
|
||||
id="reset-username"
|
||||
type="text"
|
||||
required
|
||||
className="h-11 text-base"
|
||||
value={localUsername}
|
||||
onChange={(e) => setLocalUsername(e.target.value)}
|
||||
disabled={resetLoading}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full h-11 text-base font-semibold"
|
||||
disabled={resetLoading || !localUsername.trim()}
|
||||
onClick={handleInitiatePasswordReset}
|
||||
>
|
||||
{resetLoading ? Spinner : t("auth.sendResetCode")}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
onClick={() => {
|
||||
setTab("external");
|
||||
if (tab === "reset") resetPasswordState();
|
||||
if (tab === "login" || tab === "signup") clearFormFields();
|
||||
}}
|
||||
aria-selected={tab === "external"}
|
||||
disabled={oidcLoading}
|
||||
>
|
||||
{t("auth.external")}
|
||||
</button>
|
||||
|
||||
{resetStep === "verify" && (
|
||||
<>
|
||||
<div className="text-center text-muted-foreground mb-4">
|
||||
<p>
|
||||
{t("auth.enterResetCode")}{" "}
|
||||
<strong>{localUsername}</strong>
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="reset-code">
|
||||
{t("auth.resetCode")}
|
||||
</Label>
|
||||
<Input
|
||||
id="reset-code"
|
||||
type="text"
|
||||
required
|
||||
maxLength={6}
|
||||
className="h-11 text-base text-center text-lg tracking-widest"
|
||||
value={resetCode}
|
||||
onChange={(e) =>
|
||||
setResetCode(e.target.value.replace(/\D/g, ""))
|
||||
}
|
||||
disabled={resetLoading}
|
||||
placeholder="000000"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full h-11 text-base font-semibold"
|
||||
disabled={resetLoading || resetCode.length !== 6}
|
||||
onClick={handleVerifyResetCode}
|
||||
>
|
||||
{resetLoading ? Spinner : t("auth.verifyCodeButton")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full h-11 text-base font-semibold"
|
||||
disabled={resetLoading}
|
||||
onClick={() => {
|
||||
setResetStep("initiate");
|
||||
setResetCode("");
|
||||
}}
|
||||
>
|
||||
{t("common.back")}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{resetStep === "newPassword" && !resetSuccess && (
|
||||
<>
|
||||
<div className="text-center text-muted-foreground mb-4">
|
||||
<p>
|
||||
{t("auth.enterNewPassword")}{" "}
|
||||
<strong>{localUsername}</strong>
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-5">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="new-p assword">
|
||||
{t("auth.newPassword")}
|
||||
</Label>
|
||||
<PasswordInput
|
||||
id="new-password"
|
||||
required
|
||||
className="h-11 text-base focus:ring-2 focus:ring-primary/50 transition-all duration-200"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
disabled={resetLoading}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="confirm-password">
|
||||
{t("auth.confirmNewPassword")}
|
||||
</Label>
|
||||
<PasswordInput
|
||||
id="confirm-password"
|
||||
required
|
||||
className="h-11 text-base focus:ring-2 focus:ring-primary/50 transition-all duration-200"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
disabled={resetLoading}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full h-11 text-base font-semibold"
|
||||
disabled={
|
||||
resetLoading || !newPassword || !confirmPassword
|
||||
}
|
||||
onClick={handleCompletePasswordReset}
|
||||
>
|
||||
{resetLoading
|
||||
? Spinner
|
||||
: t("auth.resetPasswordButton")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full h-11 text-base font-semibold"
|
||||
disabled={resetLoading}
|
||||
onClick={() => {
|
||||
setResetStep("verify");
|
||||
setNewPassword("");
|
||||
setConfirmPassword("");
|
||||
}}
|
||||
>
|
||||
{t("common.back")}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="mb-6 text-center">
|
||||
<h2 className="text-xl font-bold mb-1">
|
||||
{tab === "login"
|
||||
? t("auth.loginTitle")
|
||||
: tab === "signup"
|
||||
? t("auth.registerTitle")
|
||||
: tab === "external"
|
||||
? t("auth.loginWithExternal")
|
||||
: t("auth.forgotPassword")}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{tab === "external" || tab === "reset" ? (
|
||||
<div className="flex flex-col gap-5">
|
||||
{tab === "external" && (
|
||||
<>
|
||||
<div className="text-center text-muted-foreground mb-4">
|
||||
<p>{t("auth.loginWithExternalDesc")}</p>
|
||||
</div>
|
||||
{(() => {
|
||||
if (isElectron()) {
|
||||
return (
|
||||
<div className="text-center p-4 bg-muted/50 rounded-lg border">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("auth.externalNotSupportedInElectron")}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full h-11 mt-2 text-base font-semibold"
|
||||
disabled={oidcLoading}
|
||||
onClick={handleOIDCLogin}
|
||||
>
|
||||
{oidcLoading
|
||||
? Spinner
|
||||
: t("auth.loginWithExternal")}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
})()}
|
||||
</>
|
||||
)}
|
||||
{tab === "reset" && (
|
||||
<>
|
||||
{resetStep === "initiate" && (
|
||||
<>
|
||||
<div className="text-center text-muted-foreground mb-4">
|
||||
<p>{t("auth.resetCodeDesc")}</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="reset-username">
|
||||
{t("common.username")}
|
||||
</Label>
|
||||
<Input
|
||||
id="reset-username"
|
||||
type="text"
|
||||
required
|
||||
className="h-11 text-base"
|
||||
value={localUsername}
|
||||
onChange={(e) => setLocalUsername(e.target.value)}
|
||||
disabled={resetLoading}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full h-11 text-base font-semibold"
|
||||
disabled={resetLoading || !localUsername.trim()}
|
||||
onClick={handleInitiatePasswordReset}
|
||||
>
|
||||
{resetLoading ? Spinner : t("auth.sendResetCode")}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{resetStep === "verify" && (
|
||||
<>
|
||||
<div className="text-center text-muted-foreground mb-4">
|
||||
<p>
|
||||
{t("auth.enterResetCode")}{" "}
|
||||
<strong>{localUsername}</strong>
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="reset-code">
|
||||
{t("auth.resetCode")}
|
||||
</Label>
|
||||
<Input
|
||||
id="reset-code"
|
||||
type="text"
|
||||
required
|
||||
maxLength={6}
|
||||
className="h-11 text-base text-center text-lg tracking-widest"
|
||||
value={resetCode}
|
||||
onChange={(e) =>
|
||||
setResetCode(e.target.value.replace(/\D/g, ""))
|
||||
}
|
||||
disabled={resetLoading}
|
||||
placeholder="000000"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full h-11 text-base font-semibold"
|
||||
disabled={resetLoading || resetCode.length !== 6}
|
||||
onClick={handleVerifyResetCode}
|
||||
>
|
||||
{resetLoading
|
||||
? Spinner
|
||||
: t("auth.verifyCodeButton")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full h-11 text-base font-semibold"
|
||||
disabled={resetLoading}
|
||||
onClick={() => {
|
||||
setResetStep("initiate");
|
||||
setResetCode("");
|
||||
}}
|
||||
>
|
||||
{t("common.back")}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
{resetStep === "newPassword" && !resetSuccess && (
|
||||
<>
|
||||
<div className="text-center text-muted-foreground mb-4">
|
||||
<p>
|
||||
{t("auth.enterNewPassword")}{" "}
|
||||
<strong>{localUsername}</strong>
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-5">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="new-p assword">
|
||||
{t("auth.newPassword")}
|
||||
</Label>
|
||||
<PasswordInput
|
||||
id="new-password"
|
||||
required
|
||||
className="h-11 text-base focus:ring-2 focus:ring-primary/50 transition-all duration-200"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
disabled={resetLoading}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="confirm-password">
|
||||
{t("auth.confirmNewPassword")}
|
||||
</Label>
|
||||
<PasswordInput
|
||||
id="confirm-password"
|
||||
required
|
||||
className="h-11 text-base focus:ring-2 focus:ring-primary/50 transition-all duration-200"
|
||||
value={confirmPassword}
|
||||
onChange={(e) =>
|
||||
setConfirmPassword(e.target.value)
|
||||
}
|
||||
disabled={resetLoading}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full h-11 text-base font-semibold"
|
||||
disabled={
|
||||
resetLoading || !newPassword || !confirmPassword
|
||||
}
|
||||
onClick={handleCompletePasswordReset}
|
||||
>
|
||||
{resetLoading
|
||||
? Spinner
|
||||
: t("auth.resetPasswordButton")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full h-11 text-base font-semibold"
|
||||
disabled={resetLoading}
|
||||
onClick={() => {
|
||||
setResetStep("verify");
|
||||
setNewPassword("");
|
||||
setConfirmPassword("");
|
||||
}}
|
||||
>
|
||||
{t("common.back")}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<form className="flex flex-col gap-5" onSubmit={handleSubmit}>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="username">{t("common.username")}</Label>
|
||||
<Input
|
||||
id="username"
|
||||
type="text"
|
||||
required
|
||||
className="h-11 text-base"
|
||||
value={localUsername}
|
||||
onChange={(e) => setLocalUsername(e.target.value)}
|
||||
disabled={loading || internalLoggedIn}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="password">{t("common.password")}</Label>
|
||||
<PasswordInput
|
||||
id="password"
|
||||
required
|
||||
className="h-11 text-base"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
disabled={loading || internalLoggedIn}
|
||||
/>
|
||||
</div>
|
||||
{tab === "signup" && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="signup-confirm-password">
|
||||
{t("common.confirmPassword")}
|
||||
</Label>
|
||||
<PasswordInput
|
||||
id="signup-confirm-password"
|
||||
required
|
||||
className="h-11 text-base"
|
||||
value={signupConfirmPassword}
|
||||
onChange={(e) => setSignupConfirmPassword(e.target.value)}
|
||||
disabled={loading || internalLoggedIn}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full h-11 mt-2 text-base font-semibold"
|
||||
) : (
|
||||
<form className="flex flex-col gap-5" onSubmit={handleSubmit}>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="username">{t("common.username")}</Label>
|
||||
<Input
|
||||
id="username"
|
||||
type="text"
|
||||
required
|
||||
className="h-11 text-base"
|
||||
value={localUsername}
|
||||
onChange={(e) => setLocalUsername(e.target.value)}
|
||||
disabled={loading || internalLoggedIn}
|
||||
>
|
||||
{loading
|
||||
? Spinner
|
||||
: tab === "login"
|
||||
? t("common.login")
|
||||
: t("auth.signUp")}
|
||||
</Button>
|
||||
{tab === "login" && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full h-11 text-base font-semibold"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="password">{t("common.password")}</Label>
|
||||
<PasswordInput
|
||||
id="password"
|
||||
required
|
||||
className="h-11 text-base"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
disabled={loading || internalLoggedIn}
|
||||
/>
|
||||
</div>
|
||||
{tab === "signup" && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="signup-confirm-password">
|
||||
{t("common.confirmPassword")}
|
||||
</Label>
|
||||
<PasswordInput
|
||||
id="signup-confirm-password"
|
||||
required
|
||||
className="h-11 text-base"
|
||||
value={signupConfirmPassword}
|
||||
onChange={(e) => setSignupConfirmPassword(e.target.value)}
|
||||
disabled={loading || internalLoggedIn}
|
||||
onClick={() => {
|
||||
setTab("reset");
|
||||
resetPasswordState();
|
||||
clearFormFields();
|
||||
}}
|
||||
>
|
||||
{t("auth.resetPasswordButton")}
|
||||
</Button>
|
||||
)}
|
||||
</form>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full h-11 mt-2 text-base font-semibold"
|
||||
disabled={loading || internalLoggedIn}
|
||||
>
|
||||
{loading
|
||||
? Spinner
|
||||
: tab === "login"
|
||||
? t("common.login")
|
||||
: t("auth.signUp")}
|
||||
</Button>
|
||||
{tab === "login" && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full h-11 text-base font-semibold"
|
||||
disabled={loading || internalLoggedIn}
|
||||
onClick={() => {
|
||||
setTab("reset");
|
||||
resetPasswordState();
|
||||
clearFormFields();
|
||||
}}
|
||||
>
|
||||
{t("auth.resetPasswordButton")}
|
||||
</Button>
|
||||
)}
|
||||
</form>
|
||||
)}
|
||||
|
||||
<div className="mt-6 pt-4 border-t border-dark-border space-y-4">
|
||||
<div className="mt-6 pt-4 border-t border-dark-border space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label className="text-sm text-muted-foreground">
|
||||
{t("common.language")}
|
||||
</Label>
|
||||
</div>
|
||||
<LanguageSwitcher />
|
||||
</div>
|
||||
{isElectron() && currentServerUrl && (
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label className="text-sm text-muted-foreground">
|
||||
{t("common.language")}
|
||||
Server
|
||||
</Label>
|
||||
</div>
|
||||
<LanguageSwitcher />
|
||||
</div>
|
||||
{isElectron() && currentServerUrl && (
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label className="text-sm text-muted-foreground">
|
||||
Server
|
||||
</Label>
|
||||
<div className="text-xs text-muted-foreground truncate max-w-[200px]">
|
||||
{currentServerUrl}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground truncate max-w-[200px]">
|
||||
{currentServerUrl}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowServerConfig(true)}
|
||||
className="h-8 px-3"
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowServerConfig(true)}
|
||||
className="h-8 px-3"
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import React, { useState } from "react";
|
||||
import { ChevronUp, User2, HardDrive, Menu, ChevronRight } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getCookie, setCookie, isElectron, logoutUser } from "@/ui/main-axios.ts";
|
||||
import {
|
||||
getCookie,
|
||||
setCookie,
|
||||
isElectron,
|
||||
logoutUser,
|
||||
} from "@/ui/main-axios.ts";
|
||||
|
||||
import {
|
||||
Sidebar,
|
||||
@@ -68,19 +73,15 @@ interface SidebarProps {
|
||||
|
||||
async function handleLogout() {
|
||||
try {
|
||||
// Call backend logout endpoint to clear HttpOnly cookie and data session
|
||||
await logoutUser();
|
||||
|
||||
// Clear any local storage (for Electron)
|
||||
|
||||
if (isElectron()) {
|
||||
localStorage.removeItem("jwt");
|
||||
}
|
||||
|
||||
// Reload the page to reset the application state
|
||||
|
||||
window.location.reload();
|
||||
} catch (error) {
|
||||
console.error("Logout failed:", error);
|
||||
// Even if logout fails, reload the page to reset state
|
||||
window.location.reload();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,14 +57,11 @@ interface LeftSidebarProps {
|
||||
|
||||
async function handleLogout() {
|
||||
try {
|
||||
// Call backend logout endpoint to clear HttpOnly cookie and data session
|
||||
await logoutUser();
|
||||
|
||||
// Reload the page to reset the application state
|
||||
|
||||
window.location.reload();
|
||||
} catch (error) {
|
||||
console.error("Logout failed:", error);
|
||||
// Even if logout fails, reload the page to reset state
|
||||
window.location.reload();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,35 +48,25 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
||||
isVisibleRef.current = isVisible;
|
||||
}, [isVisible]);
|
||||
|
||||
// Monitor authentication state - Linus principle: explicit state management
|
||||
useEffect(() => {
|
||||
const checkAuth = () => {
|
||||
const jwtToken = getCookie("jwt");
|
||||
const isAuth = !!(jwtToken && jwtToken.trim() !== "");
|
||||
|
||||
// Only update state if it actually changed - prevent unnecessary re-renders
|
||||
setIsAuthenticated(prev => {
|
||||
setIsAuthenticated((prev) => {
|
||||
if (prev !== isAuth) {
|
||||
console.debug("Mobile Auth State Changed:", {
|
||||
from: prev,
|
||||
to: isAuth,
|
||||
jwtPresent: !!jwtToken,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
return isAuth;
|
||||
}
|
||||
return prev; // No change, don't trigger re-render
|
||||
return prev;
|
||||
});
|
||||
};
|
||||
|
||||
// Check immediately
|
||||
checkAuth();
|
||||
|
||||
// Reduced frequency - check every 5 seconds instead of every second
|
||||
const authCheckInterval = setInterval(checkAuth, 5000);
|
||||
|
||||
return () => clearInterval(authCheckInterval);
|
||||
}, []); // No dependencies - prevent infinite loop
|
||||
}, []);
|
||||
|
||||
function hardRefresh() {
|
||||
try {
|
||||
@@ -139,8 +129,6 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
||||
[terminal],
|
||||
);
|
||||
|
||||
// Resize handling optimized to avoid conflicts - Linus principle: eliminate duplicate complexity
|
||||
|
||||
function handleWindowResize() {
|
||||
if (!isVisibleRef.current) return;
|
||||
fitAddonRef.current?.fit();
|
||||
@@ -174,10 +162,10 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
||||
else if (msg.type === "error")
|
||||
terminal.writeln(`\r\n[${t("terminal.error")}] ${msg.message}`);
|
||||
else if (msg.type === "connected") {
|
||||
isConnectingRef.current = false; // Clear connecting state
|
||||
isConnectingRef.current = false;
|
||||
} else if (msg.type === "disconnected") {
|
||||
wasDisconnectedBySSH.current = true;
|
||||
isConnectingRef.current = false; // Clear connecting state
|
||||
isConnectingRef.current = false;
|
||||
terminal.writeln(
|
||||
`\r\n[${msg.message || t("terminal.disconnected")}]`,
|
||||
);
|
||||
@@ -186,17 +174,13 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
||||
});
|
||||
|
||||
ws.addEventListener("close", (event) => {
|
||||
isConnectingRef.current = false; // Clear connecting state
|
||||
isConnectingRef.current = false;
|
||||
|
||||
// Handle authentication errors (code 1008)
|
||||
if (event.code === 1008) {
|
||||
console.error("WebSocket authentication failed:", event.reason);
|
||||
terminal.writeln(`\r\n[Authentication failed - please re-login]`);
|
||||
|
||||
// Clear invalid JWT token
|
||||
localStorage.removeItem("jwt");
|
||||
|
||||
// Don't attempt to reconnect on auth failure
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -206,7 +190,7 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
||||
});
|
||||
|
||||
ws.addEventListener("error", () => {
|
||||
isConnectingRef.current = false; // Clear connecting state
|
||||
isConnectingRef.current = false;
|
||||
terminal.writeln(`\r\n[${t("terminal.connectionError")}]`);
|
||||
});
|
||||
}
|
||||
@@ -214,9 +198,7 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
||||
useEffect(() => {
|
||||
if (!terminal || !xtermRef.current || !hostConfig) return;
|
||||
|
||||
// Critical auth check - prevent terminal setup without authentication - Linus principle: fail fast
|
||||
if (!isAuthenticated) {
|
||||
console.debug("Terminal setup delayed - waiting for authentication");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -231,7 +213,6 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
||||
allowTransparency: true,
|
||||
convertEol: true,
|
||||
windowsMode: false,
|
||||
// Keep Option key for special characters on macOS (false = allows special chars, true = Meta key)
|
||||
macOptionIsMeta: false,
|
||||
macOptionClickForcesSelection: false,
|
||||
rightClickSelectsWord: false,
|
||||
@@ -271,7 +252,7 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
||||
fitAddonRef.current?.fit();
|
||||
if (terminal) scheduleNotify(terminal.cols, terminal.rows);
|
||||
hardRefresh();
|
||||
}, 150); // Increased debounce for better stability
|
||||
}, 150);
|
||||
});
|
||||
|
||||
resizeObserver.observe(xtermRef.current);
|
||||
@@ -280,24 +261,22 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
||||
(document as any).fonts?.ready instanceof Promise
|
||||
? (document as any).fonts.ready
|
||||
: Promise.resolve();
|
||||
// Show terminal immediately - better UX for mobile
|
||||
setVisible(true);
|
||||
|
||||
readyFonts.then(() => {
|
||||
// Fixed delay and authentication check - Linus principle: eliminate race conditions
|
||||
setTimeout(() => {
|
||||
fitAddon.fit();
|
||||
if (terminal) scheduleNotify(terminal.cols, terminal.rows);
|
||||
hardRefresh();
|
||||
|
||||
// Verify authentication before attempting WebSocket connection
|
||||
const jwtToken = getCookie("jwt");
|
||||
if (!jwtToken || jwtToken.trim() === "") {
|
||||
console.warn("WebSocket connection delayed - no authentication token");
|
||||
console.warn(
|
||||
"WebSocket connection delayed - no authentication token",
|
||||
);
|
||||
setIsConnected(false);
|
||||
setIsConnecting(false);
|
||||
setConnectionError("Authentication required");
|
||||
// Don't show toast here - let auth system handle it
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -325,27 +304,24 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
||||
})()
|
||||
: `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}/ssh/websocket/`;
|
||||
|
||||
// Prevent duplicate connections - Linus principle: fail fast
|
||||
if (isConnectingRef.current) {
|
||||
console.debug("Skipping connection - already connecting");
|
||||
return;
|
||||
}
|
||||
|
||||
isConnectingRef.current = true;
|
||||
|
||||
// Clean up existing connection to prevent duplicates - Linus principle: eliminate complexity
|
||||
if (webSocketRef.current && webSocketRef.current.readyState !== WebSocket.CLOSED) {
|
||||
console.log("Closing existing WebSocket connection before creating new one");
|
||||
if (
|
||||
webSocketRef.current &&
|
||||
webSocketRef.current.readyState !== WebSocket.CLOSED
|
||||
) {
|
||||
webSocketRef.current.close();
|
||||
}
|
||||
|
||||
// Clear existing ping interval
|
||||
if (pingIntervalRef.current) {
|
||||
clearInterval(pingIntervalRef.current);
|
||||
pingIntervalRef.current = null;
|
||||
}
|
||||
|
||||
// Add JWT token as query parameter for authentication
|
||||
const wsUrl = `${baseWsUrl}?token=${encodeURIComponent(jwtToken)}`;
|
||||
|
||||
setIsConnecting(true);
|
||||
@@ -356,7 +332,7 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
||||
wasDisconnectedBySSH.current = false;
|
||||
|
||||
setupWebSocketListeners(ws, cols, rows);
|
||||
}, 200); // Increased from 100ms to 200ms for auth stability
|
||||
}, 200);
|
||||
});
|
||||
|
||||
return () => {
|
||||
@@ -369,7 +345,7 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
||||
}
|
||||
webSocketRef.current?.close();
|
||||
};
|
||||
}, [xtermRef, terminal, hostConfig]); // Removed isAuthenticated to prevent infinite loop
|
||||
}, [xtermRef, terminal, hostConfig]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isVisible && fitAddonRef.current) {
|
||||
|
||||
@@ -180,8 +180,6 @@ export function HomepageAuth({
|
||||
throw new Error(t("errors.loginFailed"));
|
||||
}
|
||||
|
||||
// JWT token is now automatically set as HttpOnly cookie by backend
|
||||
console.log("Login successful - JWT set as secure HttpOnly cookie");
|
||||
[meRes] = await Promise.all([getUserInfo()]);
|
||||
|
||||
setInternalLoggedIn(true);
|
||||
@@ -214,7 +212,6 @@ export function HomepageAuth({
|
||||
setIsAdmin(false);
|
||||
setUsername(null);
|
||||
setUserId(null);
|
||||
// HttpOnly cookies cannot be cleared from JavaScript - backend handles this
|
||||
if (err?.response?.data?.error?.includes("Database")) {
|
||||
setDbError(t("errors.databaseConnection"));
|
||||
} else {
|
||||
@@ -286,12 +283,13 @@ export function HomepageAuth({
|
||||
|
||||
setResetSuccess(true);
|
||||
toast.success(t("messages.passwordResetSuccess"));
|
||||
|
||||
// Immediately redirect to login after successful reset
|
||||
|
||||
setTab("login");
|
||||
resetPasswordState();
|
||||
} catch (err: any) {
|
||||
toast.error(err?.response?.data?.error || t("errors.failedCompleteReset"));
|
||||
toast.error(
|
||||
err?.response?.data?.error || t("errors.failedCompleteReset"),
|
||||
);
|
||||
} finally {
|
||||
setResetLoading(false);
|
||||
}
|
||||
@@ -330,8 +328,6 @@ export function HomepageAuth({
|
||||
throw new Error(t("errors.loginFailed"));
|
||||
}
|
||||
|
||||
// JWT token is now automatically set as HttpOnly cookie by backend
|
||||
console.log("TOTP login successful - JWT set as secure HttpOnly cookie");
|
||||
const meRes = await getUserInfo();
|
||||
|
||||
setInternalLoggedIn(true);
|
||||
@@ -400,8 +396,6 @@ export function HomepageAuth({
|
||||
setOidcLoading(true);
|
||||
setError(null);
|
||||
|
||||
// JWT token is now automatically set as HttpOnly cookie by backend
|
||||
console.log("OIDC login successful - JWT set as secure HttpOnly cookie");
|
||||
getUserInfo()
|
||||
.then((meRes) => {
|
||||
setInternalLoggedIn(true);
|
||||
@@ -429,7 +423,6 @@ export function HomepageAuth({
|
||||
setIsAdmin(false);
|
||||
setUsername(null);
|
||||
setUserId(null);
|
||||
// HttpOnly cookies cannot be cleared from JavaScript - backend handles this
|
||||
window.history.replaceState(
|
||||
{},
|
||||
document.title,
|
||||
@@ -528,336 +521,329 @@ export function HomepageAuth({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!internalLoggedIn &&
|
||||
!authLoading &&
|
||||
!totpRequired && (
|
||||
<>
|
||||
<div className="flex gap-2 mb-6">
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex-1 py-2 text-base font-medium rounded-md transition-all",
|
||||
tab === "login"
|
||||
? "bg-primary text-primary-foreground shadow"
|
||||
: "bg-muted text-muted-foreground hover:bg-accent",
|
||||
)}
|
||||
onClick={() => {
|
||||
setTab("login");
|
||||
if (tab === "reset") resetPasswordState();
|
||||
if (tab === "signup") clearFormFields();
|
||||
}}
|
||||
aria-selected={tab === "login"}
|
||||
disabled={loading || firstUser}
|
||||
>
|
||||
{t("common.login")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex-1 py-2 text-base font-medium rounded-md transition-all",
|
||||
tab === "signup"
|
||||
? "bg-primary text-primary-foreground shadow"
|
||||
: "bg-muted text-muted-foreground hover:bg-accent",
|
||||
)}
|
||||
onClick={() => {
|
||||
setTab("signup");
|
||||
if (tab === "reset") resetPasswordState();
|
||||
if (tab === "login") clearFormFields();
|
||||
}}
|
||||
aria-selected={tab === "signup"}
|
||||
disabled={loading || !registrationAllowed}
|
||||
>
|
||||
{t("common.register")}
|
||||
</button>
|
||||
{oidcConfigured && (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex-1 py-2 text-base font-medium rounded-md transition-all",
|
||||
tab === "external"
|
||||
? "bg-primary text-primary-foreground shadow"
|
||||
: "bg-muted text-muted-foreground hover:bg-accent",
|
||||
)}
|
||||
onClick={() => {
|
||||
setTab("external");
|
||||
if (tab === "reset") resetPasswordState();
|
||||
if (tab === "login" || tab === "signup") clearFormFields();
|
||||
}}
|
||||
aria-selected={tab === "external"}
|
||||
disabled={oidcLoading}
|
||||
>
|
||||
{t("auth.external")}
|
||||
</button>
|
||||
{!internalLoggedIn && !authLoading && !totpRequired && (
|
||||
<>
|
||||
<div className="flex gap-2 mb-6">
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex-1 py-2 text-base font-medium rounded-md transition-all",
|
||||
tab === "login"
|
||||
? "bg-primary text-primary-foreground shadow"
|
||||
: "bg-muted text-muted-foreground hover:bg-accent",
|
||||
)}
|
||||
</div>
|
||||
<div className="mb-6 text-center">
|
||||
<h2 className="text-xl font-bold mb-1">
|
||||
{tab === "login"
|
||||
? t("auth.loginTitle")
|
||||
: tab === "signup"
|
||||
? t("auth.registerTitle")
|
||||
: tab === "external"
|
||||
? t("auth.loginWithExternal")
|
||||
: t("auth.forgotPassword")}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{tab === "external" || tab === "reset" ? (
|
||||
<div className="flex flex-col gap-5">
|
||||
{tab === "external" && (
|
||||
<>
|
||||
<div className="text-center text-muted-foreground mb-4">
|
||||
<p>{t("auth.loginWithExternalDesc")}</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full h-11 mt-2 text-base font-semibold"
|
||||
disabled={oidcLoading}
|
||||
onClick={handleOIDCLogin}
|
||||
>
|
||||
{oidcLoading ? Spinner : t("auth.loginWithExternal")}
|
||||
</Button>
|
||||
</>
|
||||
onClick={() => {
|
||||
setTab("login");
|
||||
if (tab === "reset") resetPasswordState();
|
||||
if (tab === "signup") clearFormFields();
|
||||
}}
|
||||
aria-selected={tab === "login"}
|
||||
disabled={loading || firstUser}
|
||||
>
|
||||
{t("common.login")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex-1 py-2 text-base font-medium rounded-md transition-all",
|
||||
tab === "signup"
|
||||
? "bg-primary text-primary-foreground shadow"
|
||||
: "bg-muted text-muted-foreground hover:bg-accent",
|
||||
)}
|
||||
onClick={() => {
|
||||
setTab("signup");
|
||||
if (tab === "reset") resetPasswordState();
|
||||
if (tab === "login") clearFormFields();
|
||||
}}
|
||||
aria-selected={tab === "signup"}
|
||||
disabled={loading || !registrationAllowed}
|
||||
>
|
||||
{t("common.register")}
|
||||
</button>
|
||||
{oidcConfigured && (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex-1 py-2 text-base font-medium rounded-md transition-all",
|
||||
tab === "external"
|
||||
? "bg-primary text-primary-foreground shadow"
|
||||
: "bg-muted text-muted-foreground hover:bg-accent",
|
||||
)}
|
||||
{tab === "reset" && (
|
||||
<>
|
||||
{resetStep === "initiate" && (
|
||||
<>
|
||||
<div className="text-center text-muted-foreground mb-4">
|
||||
<p>{t("auth.resetCodeDesc")}</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="reset-username">
|
||||
{t("common.username")}
|
||||
</Label>
|
||||
<Input
|
||||
id="reset-username"
|
||||
type="text"
|
||||
required
|
||||
className="h-11 text-base"
|
||||
value={localUsername}
|
||||
onChange={(e) => setLocalUsername(e.target.value)}
|
||||
disabled={resetLoading}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full h-11 text-base font-semibold"
|
||||
disabled={resetLoading || !localUsername.trim()}
|
||||
onClick={handleInitiatePasswordReset}
|
||||
>
|
||||
{resetLoading ? Spinner : t("auth.sendResetCode")}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
onClick={() => {
|
||||
setTab("external");
|
||||
if (tab === "reset") resetPasswordState();
|
||||
if (tab === "login" || tab === "signup") clearFormFields();
|
||||
}}
|
||||
aria-selected={tab === "external"}
|
||||
disabled={oidcLoading}
|
||||
>
|
||||
{t("auth.external")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="mb-6 text-center">
|
||||
<h2 className="text-xl font-bold mb-1">
|
||||
{tab === "login"
|
||||
? t("auth.loginTitle")
|
||||
: tab === "signup"
|
||||
? t("auth.registerTitle")
|
||||
: tab === "external"
|
||||
? t("auth.loginWithExternal")
|
||||
: t("auth.forgotPassword")}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{resetStep === "verify" && (
|
||||
<>
|
||||
<div className="text-center text-muted-foreground mb-4">
|
||||
<p>
|
||||
{t("auth.enterResetCode")}{" "}
|
||||
<strong>{localUsername}</strong>
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="reset-code">
|
||||
{t("auth.resetCode")}
|
||||
</Label>
|
||||
<Input
|
||||
id="reset-code"
|
||||
type="text"
|
||||
required
|
||||
maxLength={6}
|
||||
className="h-11 text-base text-center text-lg tracking-widest"
|
||||
value={resetCode}
|
||||
onChange={(e) =>
|
||||
setResetCode(e.target.value.replace(/\D/g, ""))
|
||||
}
|
||||
disabled={resetLoading}
|
||||
placeholder="000000"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full h-11 text-base font-semibold"
|
||||
disabled={resetLoading || resetCode.length !== 6}
|
||||
onClick={handleVerifyResetCode}
|
||||
>
|
||||
{resetLoading
|
||||
? Spinner
|
||||
: t("auth.verifyCodeButton")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full h-11 text-base font-semibold"
|
||||
disabled={resetLoading}
|
||||
onClick={() => {
|
||||
setResetStep("initiate");
|
||||
setResetCode("");
|
||||
}}
|
||||
>
|
||||
{t("common.back")}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
{resetStep === "newPassword" && !resetSuccess && (
|
||||
<>
|
||||
<div className="text-center text-muted-foreground mb-4">
|
||||
<p>
|
||||
{t("auth.enterNewPassword")}{" "}
|
||||
<strong>{localUsername}</strong>
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-5">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="new-password">
|
||||
{t("auth.newPassword")}
|
||||
</Label>
|
||||
<PasswordInput
|
||||
id="new-password"
|
||||
required
|
||||
className="h-11 text-base focus:ring-2 focus:ring-primary/50 transition-all duration-200"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
disabled={resetLoading}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="confirm-password">
|
||||
{t("auth.confirmNewPassword")}
|
||||
</Label>
|
||||
<PasswordInput
|
||||
id="confirm-password"
|
||||
required
|
||||
className="h-11 text-base focus:ring-2 focus:ring-primary/50 transition-all duration-200"
|
||||
value={confirmPassword}
|
||||
onChange={(e) =>
|
||||
setConfirmPassword(e.target.value)
|
||||
}
|
||||
disabled={resetLoading}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full h-11 text-base font-semibold"
|
||||
disabled={
|
||||
resetLoading || !newPassword || !confirmPassword
|
||||
}
|
||||
onClick={handleCompletePasswordReset}
|
||||
>
|
||||
{resetLoading
|
||||
? Spinner
|
||||
: t("auth.resetPasswordButton")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full h-11 text-base font-semibold"
|
||||
disabled={resetLoading}
|
||||
onClick={() => {
|
||||
setResetStep("verify");
|
||||
setNewPassword("");
|
||||
setConfirmPassword("");
|
||||
}}
|
||||
>
|
||||
{t("common.back")}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<form className="flex flex-col gap-5" onSubmit={handleSubmit}>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="username">{t("common.username")}</Label>
|
||||
<Input
|
||||
id="username"
|
||||
type="text"
|
||||
required
|
||||
className="h-11 text-base"
|
||||
value={localUsername}
|
||||
onChange={(e) => setLocalUsername(e.target.value)}
|
||||
disabled={loading || internalLoggedIn}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="password">{t("common.password")}</Label>
|
||||
<PasswordInput
|
||||
id="password"
|
||||
required
|
||||
className="h-11 text-base"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
disabled={loading || internalLoggedIn}
|
||||
/>
|
||||
</div>
|
||||
{tab === "signup" && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="signup-confirm-password">
|
||||
{t("common.confirmPassword")}
|
||||
</Label>
|
||||
<PasswordInput
|
||||
id="signup-confirm-password"
|
||||
required
|
||||
className="h-11 text-base"
|
||||
value={signupConfirmPassword}
|
||||
onChange={(e) => setSignupConfirmPassword(e.target.value)}
|
||||
disabled={loading || internalLoggedIn}
|
||||
/>
|
||||
{tab === "external" || tab === "reset" ? (
|
||||
<div className="flex flex-col gap-5">
|
||||
{tab === "external" && (
|
||||
<>
|
||||
<div className="text-center text-muted-foreground mb-4">
|
||||
<p>{t("auth.loginWithExternalDesc")}</p>
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full h-11 mt-2 text-base font-semibold"
|
||||
disabled={loading || internalLoggedIn}
|
||||
>
|
||||
{loading
|
||||
? Spinner
|
||||
: tab === "login"
|
||||
? t("common.login")
|
||||
: t("auth.signUp")}
|
||||
</Button>
|
||||
{tab === "login" && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full h-11 text-base font-semibold"
|
||||
disabled={loading || internalLoggedIn}
|
||||
onClick={() => {
|
||||
setTab("reset");
|
||||
resetPasswordState();
|
||||
clearFormFields();
|
||||
}}
|
||||
className="w-full h-11 mt-2 text-base font-semibold"
|
||||
disabled={oidcLoading}
|
||||
onClick={handleOIDCLogin}
|
||||
>
|
||||
{t("auth.resetPasswordButton")}
|
||||
{oidcLoading ? Spinner : t("auth.loginWithExternal")}
|
||||
</Button>
|
||||
)}
|
||||
</form>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{tab === "reset" && (
|
||||
<>
|
||||
{resetStep === "initiate" && (
|
||||
<>
|
||||
<div className="text-center text-muted-foreground mb-4">
|
||||
<p>{t("auth.resetCodeDesc")}</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="reset-username">
|
||||
{t("common.username")}
|
||||
</Label>
|
||||
<Input
|
||||
id="reset-username"
|
||||
type="text"
|
||||
required
|
||||
className="h-11 text-base"
|
||||
value={localUsername}
|
||||
onChange={(e) => setLocalUsername(e.target.value)}
|
||||
disabled={resetLoading}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full h-11 text-base font-semibold"
|
||||
disabled={resetLoading || !localUsername.trim()}
|
||||
onClick={handleInitiatePasswordReset}
|
||||
>
|
||||
{resetLoading ? Spinner : t("auth.sendResetCode")}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="mt-6 pt-4 border-t border-dark-border">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label className="text-sm text-muted-foreground">
|
||||
{t("common.language")}
|
||||
</Label>
|
||||
</div>
|
||||
<LanguageSwitcher />
|
||||
</div>
|
||||
{resetStep === "verify" && (
|
||||
<>
|
||||
<div className="text-center text-muted-foreground mb-4">
|
||||
<p>
|
||||
{t("auth.enterResetCode")}{" "}
|
||||
<strong>{localUsername}</strong>
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="reset-code">
|
||||
{t("auth.resetCode")}
|
||||
</Label>
|
||||
<Input
|
||||
id="reset-code"
|
||||
type="text"
|
||||
required
|
||||
maxLength={6}
|
||||
className="h-11 text-base text-center text-lg tracking-widest"
|
||||
value={resetCode}
|
||||
onChange={(e) =>
|
||||
setResetCode(e.target.value.replace(/\D/g, ""))
|
||||
}
|
||||
disabled={resetLoading}
|
||||
placeholder="000000"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full h-11 text-base font-semibold"
|
||||
disabled={resetLoading || resetCode.length !== 6}
|
||||
onClick={handleVerifyResetCode}
|
||||
>
|
||||
{resetLoading ? Spinner : t("auth.verifyCodeButton")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full h-11 text-base font-semibold"
|
||||
disabled={resetLoading}
|
||||
onClick={() => {
|
||||
setResetStep("initiate");
|
||||
setResetCode("");
|
||||
}}
|
||||
>
|
||||
{t("common.back")}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{resetStep === "newPassword" && !resetSuccess && (
|
||||
<>
|
||||
<div className="text-center text-muted-foreground mb-4">
|
||||
<p>
|
||||
{t("auth.enterNewPassword")}{" "}
|
||||
<strong>{localUsername}</strong>
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-5">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="new-password">
|
||||
{t("auth.newPassword")}
|
||||
</Label>
|
||||
<PasswordInput
|
||||
id="new-password"
|
||||
required
|
||||
className="h-11 text-base focus:ring-2 focus:ring-primary/50 transition-all duration-200"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
disabled={resetLoading}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="confirm-password">
|
||||
{t("auth.confirmNewPassword")}
|
||||
</Label>
|
||||
<PasswordInput
|
||||
id="confirm-password"
|
||||
required
|
||||
className="h-11 text-base focus:ring-2 focus:ring-primary/50 transition-all duration-200"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
disabled={resetLoading}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full h-11 text-base font-semibold"
|
||||
disabled={
|
||||
resetLoading || !newPassword || !confirmPassword
|
||||
}
|
||||
onClick={handleCompletePasswordReset}
|
||||
>
|
||||
{resetLoading
|
||||
? Spinner
|
||||
: t("auth.resetPasswordButton")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full h-11 text-base font-semibold"
|
||||
disabled={resetLoading}
|
||||
onClick={() => {
|
||||
setResetStep("verify");
|
||||
setNewPassword("");
|
||||
setConfirmPassword("");
|
||||
}}
|
||||
>
|
||||
{t("common.back")}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
) : (
|
||||
<form className="flex flex-col gap-5" onSubmit={handleSubmit}>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="username">{t("common.username")}</Label>
|
||||
<Input
|
||||
id="username"
|
||||
type="text"
|
||||
required
|
||||
className="h-11 text-base"
|
||||
value={localUsername}
|
||||
onChange={(e) => setLocalUsername(e.target.value)}
|
||||
disabled={loading || internalLoggedIn}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="password">{t("common.password")}</Label>
|
||||
<PasswordInput
|
||||
id="password"
|
||||
required
|
||||
className="h-11 text-base"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
disabled={loading || internalLoggedIn}
|
||||
/>
|
||||
</div>
|
||||
{tab === "signup" && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="signup-confirm-password">
|
||||
{t("common.confirmPassword")}
|
||||
</Label>
|
||||
<PasswordInput
|
||||
id="signup-confirm-password"
|
||||
required
|
||||
className="h-11 text-base"
|
||||
value={signupConfirmPassword}
|
||||
onChange={(e) => setSignupConfirmPassword(e.target.value)}
|
||||
disabled={loading || internalLoggedIn}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full h-11 mt-2 text-base font-semibold"
|
||||
disabled={loading || internalLoggedIn}
|
||||
>
|
||||
{loading
|
||||
? Spinner
|
||||
: tab === "login"
|
||||
? t("common.login")
|
||||
: t("auth.signUp")}
|
||||
</Button>
|
||||
{tab === "login" && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full h-11 text-base font-semibold"
|
||||
disabled={loading || internalLoggedIn}
|
||||
onClick={() => {
|
||||
setTab("reset");
|
||||
resetPasswordState();
|
||||
clearFormFields();
|
||||
}}
|
||||
>
|
||||
{t("auth.resetPasswordButton")}
|
||||
</Button>
|
||||
)}
|
||||
</form>
|
||||
)}
|
||||
|
||||
<div className="mt-6 pt-4 border-t border-dark-border">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label className="text-sm text-muted-foreground">
|
||||
{t("common.language")}
|
||||
</Label>
|
||||
</div>
|
||||
<LanguageSwitcher />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -25,18 +25,14 @@ const AppContent: FC = () => {
|
||||
|
||||
useEffect(() => {
|
||||
const checkAuth = () => {
|
||||
// With HttpOnly cookies, we can't check for JWT presence from frontend
|
||||
// Instead, we'll try to get user info and handle the response
|
||||
setAuthLoading(true);
|
||||
getUserInfo()
|
||||
.then((meRes) => {
|
||||
setIsAuthenticated(true);
|
||||
setIsAdmin(!!meRes.is_admin);
|
||||
setUsername(meRes.username || null);
|
||||
|
||||
// Check if user data is unlocked
|
||||
|
||||
if (!meRes.data_unlocked) {
|
||||
// Data is locked - user needs to re-authenticate
|
||||
console.warn("User data is locked - re-authentication required");
|
||||
setIsAuthenticated(false);
|
||||
setIsAdmin(false);
|
||||
@@ -47,8 +43,7 @@ const AppContent: FC = () => {
|
||||
setIsAuthenticated(false);
|
||||
setIsAdmin(false);
|
||||
setUsername(null);
|
||||
|
||||
// Check if this is a session expiration error
|
||||
|
||||
const errorCode = err?.response?.data?.code;
|
||||
if (errorCode === "SESSION_EXPIRED") {
|
||||
console.warn("Session expired - please log in again");
|
||||
|
||||
@@ -60,7 +60,7 @@ async function handleLogout() {
|
||||
try {
|
||||
// Call backend logout endpoint to clear HttpOnly cookie and data session
|
||||
await logoutUser();
|
||||
|
||||
|
||||
// Reload the page to reset the application state
|
||||
window.location.reload();
|
||||
} catch (error) {
|
||||
|
||||
@@ -86,17 +86,15 @@ export function DragIndicator({
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
{/* Icon */}
|
||||
<div className="flex-shrink-0 mt-0.5">{getIcon()}</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
{/* Title */}
|
||||
<div className="text-sm font-medium text-foreground mb-2">
|
||||
{fileCount > 1 ? t("dragIndicator.batchDrag") : t("dragIndicator.dragToDesktop")}
|
||||
{fileCount > 1
|
||||
? t("dragIndicator.batchDrag")
|
||||
: t("dragIndicator.dragToDesktop")}
|
||||
</div>
|
||||
|
||||
{/* Status text */}
|
||||
<div
|
||||
className={cn(
|
||||
"text-xs mb-3",
|
||||
@@ -110,7 +108,6 @@ export function DragIndicator({
|
||||
{getStatusText()}
|
||||
</div>
|
||||
|
||||
{/* Progress bar */}
|
||||
{(isDownloading || isDragging) && !error && (
|
||||
<div className="w-full bg-dark-border rounded-full h-2 mb-2">
|
||||
<div
|
||||
@@ -123,14 +120,12 @@ export function DragIndicator({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Progress percentage */}
|
||||
{(isDownloading || isDragging) && !error && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{progress.toFixed(0)}%
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Drag hint */}
|
||||
{isDragging && !error && (
|
||||
<div className="text-xs text-green-500 mt-2 flex items-center gap-1">
|
||||
<Download className="w-3 h-3" />
|
||||
@@ -140,7 +135,6 @@ export function DragIndicator({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Background with animation effect */}
|
||||
{isDragging && !error && (
|
||||
<div className="absolute inset-0 rounded-lg bg-green-500/5 animate-pulse" />
|
||||
)}
|
||||
|
||||
@@ -32,7 +32,6 @@ export function useDragToDesktop({
|
||||
error: null,
|
||||
});
|
||||
|
||||
// Check if running in Electron environment
|
||||
const isElectron = () => {
|
||||
return (
|
||||
typeof window !== "undefined" &&
|
||||
@@ -41,13 +40,13 @@ export function useDragToDesktop({
|
||||
);
|
||||
};
|
||||
|
||||
// Drag single file to desktop
|
||||
const dragFileToDesktop = useCallback(
|
||||
async (file: FileItem, options: DragToDesktopOptions = {}) => {
|
||||
const { enableToast = true, onSuccess, onError } = options;
|
||||
|
||||
if (!isElectron()) {
|
||||
const error = "Drag to desktop feature is only available in desktop application";
|
||||
const error =
|
||||
"Drag to desktop feature is only available in desktop application";
|
||||
if (enableToast) toast.error(error);
|
||||
onError?.(error);
|
||||
return false;
|
||||
@@ -68,7 +67,6 @@ export function useDragToDesktop({
|
||||
error: null,
|
||||
}));
|
||||
|
||||
// Download file content
|
||||
const response = await downloadSSHFile(sshSessionId, file.path);
|
||||
|
||||
if (!response?.content) {
|
||||
@@ -77,7 +75,6 @@ export function useDragToDesktop({
|
||||
|
||||
setState((prev) => ({ ...prev, progress: 50 }));
|
||||
|
||||
// Create temporary file
|
||||
const tempResult = await window.electronAPI.createTempFile({
|
||||
fileName: file.name,
|
||||
content: response.content,
|
||||
@@ -85,12 +82,13 @@ export function useDragToDesktop({
|
||||
});
|
||||
|
||||
if (!tempResult.success) {
|
||||
throw new Error(tempResult.error || "Failed to create temporary file");
|
||||
throw new Error(
|
||||
tempResult.error || "Failed to create temporary file",
|
||||
);
|
||||
}
|
||||
|
||||
setState((prev) => ({ ...prev, progress: 80, isDragging: true }));
|
||||
|
||||
// Start dragging
|
||||
const dragResult = await window.electronAPI.startDragToDesktop({
|
||||
tempId: tempResult.tempId,
|
||||
fileName: file.name,
|
||||
@@ -108,7 +106,6 @@ export function useDragToDesktop({
|
||||
|
||||
onSuccess?.();
|
||||
|
||||
// Delayed cleanup of temporary file (give user time to complete drag)
|
||||
setTimeout(async () => {
|
||||
await window.electronAPI.cleanupTempFile(tempResult.tempId);
|
||||
setState((prev) => ({
|
||||
@@ -117,7 +114,7 @@ export function useDragToDesktop({
|
||||
isDownloading: false,
|
||||
progress: 0,
|
||||
}));
|
||||
}, 10000); // Cleanup after 10 seconds
|
||||
}, 10000);
|
||||
|
||||
return true;
|
||||
} catch (error: any) {
|
||||
@@ -143,13 +140,13 @@ export function useDragToDesktop({
|
||||
[sshSessionId, sshHost],
|
||||
);
|
||||
|
||||
// Drag multiple files to desktop (batch operation)
|
||||
const dragFilesToDesktop = useCallback(
|
||||
async (files: FileItem[], options: DragToDesktopOptions = {}) => {
|
||||
const { enableToast = true, onSuccess, onError } = options;
|
||||
|
||||
if (!isElectron()) {
|
||||
const error = "Drag to desktop feature is only available in desktop application";
|
||||
const error =
|
||||
"Drag to desktop feature is only available in desktop application";
|
||||
if (enableToast) toast.error(error);
|
||||
onError?.(error);
|
||||
return false;
|
||||
@@ -175,7 +172,6 @@ export function useDragToDesktop({
|
||||
error: null,
|
||||
}));
|
||||
|
||||
// Batch download files
|
||||
const downloadPromises = fileList.map((file) =>
|
||||
downloadSSHFile(sshSessionId, file.path),
|
||||
);
|
||||
@@ -183,7 +179,6 @@ export function useDragToDesktop({
|
||||
const responses = await Promise.all(downloadPromises);
|
||||
setState((prev) => ({ ...prev, progress: 40 }));
|
||||
|
||||
// Create temporary folder structure
|
||||
const folderName = `Files_${Date.now()}`;
|
||||
const filesData = fileList.map((file, index) => ({
|
||||
relativePath: file.name,
|
||||
@@ -197,12 +192,13 @@ export function useDragToDesktop({
|
||||
});
|
||||
|
||||
if (!tempResult.success) {
|
||||
throw new Error(tempResult.error || "Failed to create temporary folder");
|
||||
throw new Error(
|
||||
tempResult.error || "Failed to create temporary folder",
|
||||
);
|
||||
}
|
||||
|
||||
setState((prev) => ({ ...prev, progress: 80, isDragging: true }));
|
||||
|
||||
// Start dragging folder
|
||||
const dragResult = await window.electronAPI.startDragToDesktop({
|
||||
tempId: tempResult.tempId,
|
||||
fileName: folderName,
|
||||
@@ -220,7 +216,6 @@ export function useDragToDesktop({
|
||||
|
||||
onSuccess?.();
|
||||
|
||||
// Delayed cleanup of temporary folder
|
||||
setTimeout(async () => {
|
||||
await window.electronAPI.cleanupTempFile(tempResult.tempId);
|
||||
setState((prev) => ({
|
||||
@@ -229,8 +224,7 @@ export function useDragToDesktop({
|
||||
isDownloading: false,
|
||||
progress: 0,
|
||||
}));
|
||||
}, 15000); // Cleanup after 15 seconds
|
||||
|
||||
}, 15000);
|
||||
return true;
|
||||
} catch (error: any) {
|
||||
console.error("Failed to batch drag to desktop:", error);
|
||||
@@ -255,13 +249,13 @@ export function useDragToDesktop({
|
||||
[sshSessionId, sshHost, dragFileToDesktop],
|
||||
);
|
||||
|
||||
// Drag folder to desktop
|
||||
const dragFolderToDesktop = useCallback(
|
||||
async (folder: FileItem, options: DragToDesktopOptions = {}) => {
|
||||
const { enableToast = true, onSuccess, onError } = options;
|
||||
|
||||
if (!isElectron()) {
|
||||
const error = "Drag to desktop feature is only available in desktop application";
|
||||
const error =
|
||||
"Drag to desktop feature is only available in desktop application";
|
||||
if (enableToast) toast.error(error);
|
||||
onError?.(error);
|
||||
return false;
|
||||
@@ -278,9 +272,6 @@ export function useDragToDesktop({
|
||||
toast.info("Folder drag functionality is under development...");
|
||||
}
|
||||
|
||||
// TODO: Implement recursive folder download and drag
|
||||
// This requires additional API to recursively get folder contents
|
||||
|
||||
return false;
|
||||
},
|
||||
[sshSessionId, sshHost],
|
||||
|
||||
@@ -37,7 +37,6 @@ export function useDragToSystemDesktop({
|
||||
options: DragToSystemOptions;
|
||||
} | null>(null);
|
||||
|
||||
// Directory memory functionality
|
||||
const getLastSaveDirectory = async () => {
|
||||
try {
|
||||
if ("indexedDB" in window) {
|
||||
@@ -60,9 +59,7 @@ export function useDragToSystemDesktop({
|
||||
};
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("Unable to get last save directory:", error);
|
||||
}
|
||||
} catch (error) {}
|
||||
return null;
|
||||
};
|
||||
|
||||
@@ -78,19 +75,15 @@ export function useDragToSystemDesktop({
|
||||
store.put({ handle: dirHandle }, "lastSaveDir");
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("Unable to save directory record:", error);
|
||||
}
|
||||
} catch (error) {}
|
||||
};
|
||||
|
||||
// Check File System Access API support
|
||||
const isFileSystemAPISupported = () => {
|
||||
return "showSaveFilePicker" in window;
|
||||
};
|
||||
|
||||
// Check if drag has left window boundaries
|
||||
const isDraggedOutsideWindow = (e: DragEvent) => {
|
||||
const margin = 50; // Increase tolerance margin
|
||||
const margin = 50;
|
||||
return (
|
||||
e.clientX < margin ||
|
||||
e.clientX > window.innerWidth - margin ||
|
||||
@@ -99,14 +92,12 @@ export function useDragToSystemDesktop({
|
||||
);
|
||||
};
|
||||
|
||||
// Create file blob
|
||||
const createFileBlob = async (file: FileItem): Promise<Blob> => {
|
||||
const response = await downloadSSHFile(sshSessionId, file.path);
|
||||
if (!response?.content) {
|
||||
throw new Error(`Unable to get content for file ${file.name}`);
|
||||
}
|
||||
|
||||
// Convert base64 to blob
|
||||
const binaryString = atob(response.content);
|
||||
const bytes = new Uint8Array(binaryString.length);
|
||||
for (let i = 0; i < binaryString.length; i++) {
|
||||
@@ -116,9 +107,7 @@ export function useDragToSystemDesktop({
|
||||
return new Blob([bytes]);
|
||||
};
|
||||
|
||||
// Create ZIP file (for multi-file download)
|
||||
const createZipBlob = async (files: FileItem[]): Promise<Blob> => {
|
||||
// A lightweight zip library is needed here, using simple approach for now
|
||||
const JSZip = (await import("jszip")).default;
|
||||
const zip = new JSZip();
|
||||
|
||||
@@ -130,8 +119,6 @@ export function useDragToSystemDesktop({
|
||||
return await zip.generateAsync({ type: "blob" });
|
||||
};
|
||||
|
||||
|
||||
// Fallback solution: traditional download
|
||||
const fallbackDownload = (blob: Blob, fileName: string) => {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
@@ -143,7 +130,6 @@ export function useDragToSystemDesktop({
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
// Handle drag to system desktop
|
||||
const handleDragToSystem = useCallback(
|
||||
async (files: FileItem[], options: DragToSystemOptions = {}) => {
|
||||
const { enableToast = true, onSuccess, onError } = options;
|
||||
@@ -155,7 +141,6 @@ export function useDragToSystemDesktop({
|
||||
return false;
|
||||
}
|
||||
|
||||
// Filter out file types
|
||||
const fileList = files.filter((f) => f.type === "file");
|
||||
if (fileList.length === 0) {
|
||||
const error = "Only files can be dragged to desktop";
|
||||
@@ -172,12 +157,9 @@ export function useDragToSystemDesktop({
|
||||
error: null,
|
||||
}));
|
||||
|
||||
// Determine file name first (synchronously)
|
||||
const fileName = fileList.length === 1
|
||||
? fileList[0].name
|
||||
: `files_${Date.now()}.zip`;
|
||||
const fileName =
|
||||
fileList.length === 1 ? fileList[0].name : `files_${Date.now()}.zip`;
|
||||
|
||||
// For File System Access API, get the file handle FIRST to preserve user gesture
|
||||
let fileHandle: any = null;
|
||||
if (isFileSystemAPISupported()) {
|
||||
try {
|
||||
@@ -188,14 +170,21 @@ export function useDragToSystemDesktop({
|
||||
{
|
||||
description: "Files",
|
||||
accept: {
|
||||
"*/*": [".txt", ".jpg", ".png", ".pdf", ".zip", ".tar", ".gz"],
|
||||
"*/*": [
|
||||
".txt",
|
||||
".jpg",
|
||||
".png",
|
||||
".pdf",
|
||||
".zip",
|
||||
".tar",
|
||||
".gz",
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
} catch (error: any) {
|
||||
if (error.name === "AbortError") {
|
||||
// User cancelled
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
isDownloading: false,
|
||||
@@ -207,32 +196,28 @@ export function useDragToSystemDesktop({
|
||||
}
|
||||
}
|
||||
|
||||
// Now create the blob (after getting file handle)
|
||||
let blob: Blob;
|
||||
if (fileList.length === 1) {
|
||||
// Single file
|
||||
blob = await createFileBlob(fileList[0]);
|
||||
setState((prev) => ({ ...prev, progress: 70 }));
|
||||
} else {
|
||||
// Package multiple files into ZIP
|
||||
blob = await createZipBlob(fileList);
|
||||
setState((prev) => ({ ...prev, progress: 70 }));
|
||||
}
|
||||
|
||||
setState((prev) => ({ ...prev, progress: 90 }));
|
||||
|
||||
// Save the file
|
||||
if (fileHandle) {
|
||||
// Use File System Access API with pre-obtained handle
|
||||
await saveLastDirectory(fileHandle);
|
||||
const writable = await fileHandle.createWritable();
|
||||
await writable.write(blob);
|
||||
await writable.close();
|
||||
} else {
|
||||
// Fallback to traditional download
|
||||
fallbackDownload(blob, fileName);
|
||||
if (enableToast) {
|
||||
toast.info("Due to browser limitations, file will be downloaded to default download directory");
|
||||
toast.info(
|
||||
"Due to browser limitations, file will be downloaded to default download directory",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -248,14 +233,12 @@ export function useDragToSystemDesktop({
|
||||
|
||||
onSuccess?.();
|
||||
|
||||
// Reset state
|
||||
setTimeout(() => {
|
||||
setState((prev) => ({ ...prev, isDownloading: false, progress: 0 }));
|
||||
}, 1000);
|
||||
|
||||
return true;
|
||||
} catch (error: any) {
|
||||
console.error("Failed to drag to desktop:", error);
|
||||
const errorMessage = error.message || "Save failed";
|
||||
|
||||
setState((prev) => ({
|
||||
@@ -276,7 +259,6 @@ export function useDragToSystemDesktop({
|
||||
[sshSessionId],
|
||||
);
|
||||
|
||||
// Start dragging (record drag data)
|
||||
const startDragToSystem = useCallback(
|
||||
(files: FileItem[], options: DragToSystemOptions = {}) => {
|
||||
dragDataRef.current = { files, options };
|
||||
@@ -285,27 +267,22 @@ export function useDragToSystemDesktop({
|
||||
[],
|
||||
);
|
||||
|
||||
// End drag detection
|
||||
const handleDragEnd = useCallback(
|
||||
(e: DragEvent) => {
|
||||
if (!dragDataRef.current) return;
|
||||
|
||||
const { files, options } = dragDataRef.current;
|
||||
|
||||
// Check if dragged outside window
|
||||
if (isDraggedOutsideWindow(e)) {
|
||||
// Execute immediately to preserve user gesture context for showSaveFilePicker
|
||||
handleDragToSystem(files, options);
|
||||
}
|
||||
|
||||
// Clean up drag state
|
||||
dragDataRef.current = null;
|
||||
setState((prev) => ({ ...prev, isDragging: false }));
|
||||
},
|
||||
[handleDragToSystem],
|
||||
);
|
||||
|
||||
// Cancel dragging
|
||||
const cancelDragToSystem = useCallback(() => {
|
||||
dragDataRef.current = null;
|
||||
setState((prev) => ({ ...prev, isDragging: false, error: null }));
|
||||
@@ -317,6 +294,6 @@ export function useDragToSystemDesktop({
|
||||
startDragToSystem,
|
||||
handleDragEnd,
|
||||
cancelDragToSystem,
|
||||
handleDragToSystem, // Direct call version
|
||||
handleDragToSystem,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -115,8 +115,6 @@ export function setCookie(name: string, value: string, days = 7): void {
|
||||
if (isElectron()) {
|
||||
localStorage.setItem(name, value);
|
||||
} else {
|
||||
// Note: For secure authentication, cookies should be set by the backend
|
||||
// This function is kept for backward compatibility with non-auth cookies
|
||||
const expires = new Date(Date.now() + days * 864e5).toUTCString();
|
||||
document.cookie = `${name}=${encodeURIComponent(value)}; expires=${expires}; path=/`;
|
||||
}
|
||||
@@ -131,7 +129,6 @@ export function getCookie(name: string): string | undefined {
|
||||
const parts = value.split(`; ${name}=`);
|
||||
const encodedToken =
|
||||
parts.length === 2 ? parts.pop()?.split(";").shift() : undefined;
|
||||
// Decode the token since setCookie uses encodeURIComponent
|
||||
const token = encodedToken ? decodeURIComponent(encodedToken) : undefined;
|
||||
return token;
|
||||
}
|
||||
@@ -271,33 +268,26 @@ function createApiInstance(
|
||||
}
|
||||
|
||||
if (status === 401) {
|
||||
// Check if this is a session expiration (data lock) vs regular auth failure
|
||||
const errorCode = (error.response?.data as any)?.code;
|
||||
const isSessionExpired = errorCode === "SESSION_EXPIRED";
|
||||
|
||||
// Clear authentication state
|
||||
|
||||
if (isElectron()) {
|
||||
localStorage.removeItem("jwt");
|
||||
} else {
|
||||
// For web, the secure HttpOnly cookie will be cleared by the backend
|
||||
// We can't clear HttpOnly cookies from JavaScript
|
||||
localStorage.removeItem("jwt");
|
||||
}
|
||||
|
||||
// If session expired, show notification and reload page
|
||||
if (isSessionExpired && typeof window !== "undefined") {
|
||||
console.warn("Session expired - please log in again");
|
||||
|
||||
|
||||
import("sonner").then(({ toast }) => {
|
||||
toast.warning("Session expired - please log in again");
|
||||
});
|
||||
|
||||
// Trigger a page reload to redirect to login
|
||||
|
||||
setTimeout(() => window.location.reload(), 100);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return Promise.reject(error);
|
||||
},
|
||||
);
|
||||
@@ -441,7 +431,6 @@ function getApiUrl(path: string, defaultPort: number): string {
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize API instances
|
||||
function initializeApiInstances() {
|
||||
// SSH Host Management API (port 30001)
|
||||
sshHostApi = createApiInstance(getApiUrl("/ssh", 30001), "SSH_HOST");
|
||||
@@ -477,7 +466,6 @@ export let statsApi: AxiosInstance;
|
||||
// Authentication API (port 30001)
|
||||
export let authApi: AxiosInstance;
|
||||
|
||||
// Initialize API instances immediately
|
||||
initializeApiInstances();
|
||||
|
||||
function updateApiInstances() {
|
||||
@@ -488,7 +476,6 @@ function updateApiInstances() {
|
||||
|
||||
initializeApiInstances();
|
||||
|
||||
// Make configuredServerUrl available globally for components that need it
|
||||
(window as any).configuredServerUrl = configuredServerUrl;
|
||||
|
||||
systemLogger.success("All API instances updated successfully", {
|
||||
@@ -587,7 +574,6 @@ function handleApiError(error: unknown, operation: string): never {
|
||||
"SERVER_ERROR",
|
||||
);
|
||||
} else if (status === 0) {
|
||||
// Check if this is a "no server configured" error
|
||||
if (url.includes("no-server-configured")) {
|
||||
apiLogger.error(
|
||||
`No server configured: ${method} ${url}`,
|
||||
@@ -796,7 +782,9 @@ export async function getSSHHostById(hostId: number): Promise<SSHHost> {
|
||||
|
||||
export async function enableAutoStart(sshConfigId: number): Promise<any> {
|
||||
try {
|
||||
const response = await sshHostApi.post("/autostart/enable", { sshConfigId });
|
||||
const response = await sshHostApi.post("/autostart/enable", {
|
||||
sshConfigId,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "enable autostart");
|
||||
@@ -806,7 +794,7 @@ export async function enableAutoStart(sshConfigId: number): Promise<any> {
|
||||
export async function disableAutoStart(sshConfigId: number): Promise<any> {
|
||||
try {
|
||||
const response = await sshHostApi.delete("/autostart/disable", {
|
||||
data: { sshConfigId }
|
||||
data: { sshConfigId },
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
@@ -1072,7 +1060,7 @@ export async function listSSHFiles(
|
||||
return response.data || { files: [], path };
|
||||
} catch (error) {
|
||||
handleApiError(error, "list SSH files");
|
||||
return { files: [], path }; // Ensure always return correct format
|
||||
return { files: [], path };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1100,11 +1088,11 @@ export async function readSSHFile(
|
||||
});
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
// Preserve fileNotFound information for 404 errors
|
||||
if (error.response?.status === 404) {
|
||||
const customError = new Error("File not found");
|
||||
(customError as any).response = error.response;
|
||||
(customError as any).isFileNotFound = error.response.data?.fileNotFound || true;
|
||||
(customError as any).isFileNotFound =
|
||||
error.response.data?.fileNotFound || true;
|
||||
throw customError;
|
||||
}
|
||||
handleApiError(error, "read SSH file");
|
||||
@@ -1268,7 +1256,7 @@ export async function copySSHItem(
|
||||
userId,
|
||||
},
|
||||
{
|
||||
timeout: 60000, // 60 second timeout as file copying may take longer
|
||||
timeout: 60000,
|
||||
},
|
||||
);
|
||||
return response.data;
|
||||
@@ -1308,15 +1296,19 @@ export async function moveSSHItem(
|
||||
userId?: string,
|
||||
): Promise<any> {
|
||||
try {
|
||||
const response = await fileManagerApi.put("/ssh/moveItem", {
|
||||
sessionId,
|
||||
oldPath,
|
||||
newPath,
|
||||
hostId,
|
||||
userId,
|
||||
}, {
|
||||
timeout: 60000, // 60 second timeout for move operations
|
||||
});
|
||||
const response = await fileManagerApi.put(
|
||||
"/ssh/moveItem",
|
||||
{
|
||||
sessionId,
|
||||
oldPath,
|
||||
newPath,
|
||||
hostId,
|
||||
userId,
|
||||
},
|
||||
{
|
||||
timeout: 60000,
|
||||
},
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "move SSH item");
|
||||
@@ -1374,7 +1366,6 @@ export async function removeRecentFile(
|
||||
}
|
||||
}
|
||||
|
||||
// Pinned Files
|
||||
export async function getPinnedFiles(hostId: number): Promise<any> {
|
||||
try {
|
||||
const response = await authApi.get("/ssh/file_manager/pinned", {
|
||||
@@ -1420,7 +1411,6 @@ export async function removePinnedFile(
|
||||
}
|
||||
}
|
||||
|
||||
// Folder Shortcuts
|
||||
export async function getFolderShortcuts(hostId: number): Promise<any> {
|
||||
try {
|
||||
const response = await authApi.get("/ssh/file_manager/shortcuts", {
|
||||
@@ -1524,10 +1514,8 @@ export async function loginUser(
|
||||
): Promise<AuthResponse> {
|
||||
try {
|
||||
const response = await authApi.post("/users/login", { username, password });
|
||||
// JWT token is now set as secure HttpOnly cookie by backend
|
||||
// Return success status and user info
|
||||
return {
|
||||
token: "cookie-based", // Placeholder since token is in HttpOnly cookie
|
||||
token: "cookie-based",
|
||||
success: response.data.success,
|
||||
is_admin: response.data.is_admin,
|
||||
username: response.data.username,
|
||||
@@ -1537,7 +1525,10 @@ export async function loginUser(
|
||||
}
|
||||
}
|
||||
|
||||
export async function logoutUser(): Promise<{ success: boolean; message: string }> {
|
||||
export async function logoutUser(): Promise<{
|
||||
success: boolean;
|
||||
message: string;
|
||||
}> {
|
||||
try {
|
||||
const response = await authApi.post("/users/logout");
|
||||
return response.data;
|
||||
@@ -1555,7 +1546,9 @@ export async function getUserInfo(): Promise<UserInfo> {
|
||||
}
|
||||
}
|
||||
|
||||
export async function unlockUserData(password: string): Promise<{ success: boolean; message: string }> {
|
||||
export async function unlockUserData(
|
||||
password: string,
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
try {
|
||||
const response = await authApi.post("/users/unlock-data", { password });
|
||||
return response.data;
|
||||
@@ -1824,9 +1817,7 @@ export async function getUserAlerts(): Promise<{ alerts: any[] }> {
|
||||
}
|
||||
}
|
||||
|
||||
export async function dismissAlert(
|
||||
alertId: string,
|
||||
): Promise<any> {
|
||||
export async function dismissAlert(alertId: string): Promise<any> {
|
||||
try {
|
||||
const response = await authApi.post("/alerts/dismiss", { alertId });
|
||||
return response.data;
|
||||
@@ -1943,7 +1934,6 @@ export async function getCredentialFolders(): Promise<any> {
|
||||
}
|
||||
}
|
||||
|
||||
// Get SSH host with resolved credentials
|
||||
export async function getSSHHostWithCredentials(hostId: number): Promise<any> {
|
||||
try {
|
||||
const response = await sshHostApi.get(
|
||||
@@ -1955,7 +1945,6 @@ export async function getSSHHostWithCredentials(hostId: number): Promise<any> {
|
||||
}
|
||||
}
|
||||
|
||||
// Apply credential to SSH host
|
||||
export async function applyCredentialToHost(
|
||||
hostId: number,
|
||||
credentialId: number,
|
||||
@@ -1971,7 +1960,6 @@ export async function applyCredentialToHost(
|
||||
}
|
||||
}
|
||||
|
||||
// Remove credential from SSH host
|
||||
export async function removeCredentialFromHost(hostId: number): Promise<any> {
|
||||
try {
|
||||
const response = await sshHostApi.delete(`/db/host/${hostId}/credential`);
|
||||
@@ -1981,7 +1969,6 @@ export async function removeCredentialFromHost(hostId: number): Promise<any> {
|
||||
}
|
||||
}
|
||||
|
||||
// Migrate host to managed credential
|
||||
export async function migrateHostToCredential(
|
||||
hostId: number,
|
||||
credentialName: string,
|
||||
|
||||
Reference in New Issue
Block a user