database.tsā¢9.75 kB
import Database from 'better-sqlite3';
import { dirname, join, basename } from 'path';
import { existsSync, mkdirSync, readFileSync, writeFileSync, chmodSync } from 'fs';
import { randomBytes } from 'crypto';
/**
* Database initialization and management for the MCP Cut-Copy-Paste Clipboard Server
*/
export class DatabaseManager {
private db: Database.Database;
private encryptionKey: Buffer;
private keyPath: string | null = null;
private readonly isInMemory: boolean;
constructor(dbPath?: string) {
// Default to .mcp-clipboard in user's home directory
const defaultPath = join(
process.env.HOME || process.env.USERPROFILE || '.',
'.mcp-clipboard',
'clipboard.db'
);
const finalPath = dbPath || defaultPath;
this.isInMemory = finalPath === ':memory:';
// Ensure directory and encryption key exist for on-disk databases
if (!this.isInMemory) {
const dir = dirname(finalPath);
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true, mode: 0o700 });
}
try {
chmodSync(dir, 0o700);
} catch {
// Ignore chmod errors (e.g., on Windows or restricted filesystems)
}
const keyFilename =
basename(finalPath) === 'clipboard.db' ? 'clipboard.key' : `${basename(finalPath)}.key`;
this.keyPath = join(dir, keyFilename);
this.encryptionKey = this.loadOrCreateKey(this.keyPath);
} else {
this.encryptionKey = randomBytes(32);
}
this.db = new Database(finalPath);
if (!this.isInMemory) {
try {
chmodSync(finalPath, 0o600);
} catch {
// Ignore chmod errors (e.g., on Windows or restricted filesystems)
}
}
this.db.pragma('journal_mode = WAL'); // Better concurrency
this.db.pragma('foreign_keys = ON'); // Enforce foreign key constraints
this.initializeSchema();
}
/**
* Initialize database schema with all required tables
*/
private initializeSchema(): void {
// Sessions table
this.db.exec(`
CREATE TABLE IF NOT EXISTS sessions (
session_id TEXT PRIMARY KEY,
created_at INTEGER NOT NULL,
last_activity INTEGER NOT NULL
)
`);
// Clipboard buffer table (one per session)
this.db.exec(`
CREATE TABLE IF NOT EXISTS clipboard_buffer (
session_id TEXT PRIMARY KEY,
content TEXT NOT NULL,
source_file TEXT NOT NULL,
start_line INTEGER NOT NULL,
end_line INTEGER NOT NULL,
copied_at INTEGER NOT NULL,
operation_type TEXT NOT NULL CHECK(operation_type IN ('copy', 'cut')),
cut_source_original_content TEXT,
encrypted_payload TEXT,
encryption_iv TEXT,
encryption_tag TEXT,
encryption_version INTEGER DEFAULT 1,
FOREIGN KEY (session_id) REFERENCES sessions(session_id) ON DELETE CASCADE
)
`);
// Operations log table
this.db.exec(`
CREATE TABLE IF NOT EXISTS operations_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL,
operation_type TEXT NOT NULL CHECK(operation_type IN ('copy', 'cut', 'paste', 'undo')),
timestamp INTEGER NOT NULL,
source_file TEXT,
source_start_line INTEGER,
source_end_line INTEGER,
target_file TEXT,
target_line INTEGER,
content_snapshot TEXT,
undoable INTEGER DEFAULT 1 CHECK(undoable IN (0, 1)),
FOREIGN KEY (session_id) REFERENCES sessions(session_id) ON DELETE CASCADE
)
`);
// Paste history table (for undo support)
this.db.exec(`
CREATE TABLE IF NOT EXISTS paste_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL,
operation_log_id INTEGER NOT NULL,
file_path TEXT NOT NULL,
original_content TEXT NOT NULL,
modified_content TEXT NOT NULL,
paste_line INTEGER NOT NULL,
timestamp INTEGER NOT NULL,
undone INTEGER DEFAULT 0 CHECK(undone IN (0, 1)),
cut_source_file TEXT,
cut_source_content TEXT,
encrypted_payload TEXT,
encryption_iv TEXT,
encryption_tag TEXT,
encryption_version INTEGER DEFAULT 1,
FOREIGN KEY (session_id) REFERENCES sessions(session_id) ON DELETE CASCADE,
FOREIGN KEY (operation_log_id) REFERENCES operations_log(id) ON DELETE CASCADE
)
`);
// Run migrations to handle schema updates AFTER all tables are created
this.runMigrations();
// Create indexes for common queries
this.db.exec(`
CREATE INDEX IF NOT EXISTS idx_sessions_last_activity
ON sessions(last_activity);
CREATE INDEX IF NOT EXISTS idx_operations_log_session
ON operations_log(session_id, timestamp DESC);
CREATE INDEX IF NOT EXISTS idx_paste_history_session
ON paste_history(session_id, timestamp DESC);
CREATE INDEX IF NOT EXISTS idx_paste_history_undone
ON paste_history(session_id, undone, timestamp DESC);
`);
}
/**
* Run database migrations to update schema
*/
private runMigrations(): void {
// Check if cut_source_original_content column exists in clipboard_buffer
const tableInfo = this.db.pragma('table_info(clipboard_buffer)') as Array<{
name: string;
type: string;
}>;
const hasColumn = tableInfo.some((col) => col.name === 'cut_source_original_content');
if (!hasColumn) {
// Add the column if it doesn't exist
this.db.exec(`
ALTER TABLE clipboard_buffer
ADD COLUMN cut_source_original_content TEXT;
`);
}
const hasEncryptedPayload = tableInfo.some((col) => col.name === 'encrypted_payload');
if (!hasEncryptedPayload) {
this.db.exec(`
ALTER TABLE clipboard_buffer
ADD COLUMN encrypted_payload TEXT;
`);
}
const hasEncryptionIv = tableInfo.some((col) => col.name === 'encryption_iv');
if (!hasEncryptionIv) {
this.db.exec(`
ALTER TABLE clipboard_buffer
ADD COLUMN encryption_iv TEXT;
`);
}
const hasEncryptionTag = tableInfo.some((col) => col.name === 'encryption_tag');
if (!hasEncryptionTag) {
this.db.exec(`
ALTER TABLE clipboard_buffer
ADD COLUMN encryption_tag TEXT;
`);
}
const hasEncryptionVersion = tableInfo.some((col) => col.name === 'encryption_version');
if (!hasEncryptionVersion) {
this.db.exec(`
ALTER TABLE clipboard_buffer
ADD COLUMN encryption_version INTEGER DEFAULT 1;
`);
}
// Migrate paste_history table to add encryption columns
const pasteHistoryInfo = this.db.pragma('table_info(paste_history)') as Array<{
name: string;
type: string;
}>;
const hasEncryptedPayloadPH = pasteHistoryInfo.some((col) => col.name === 'encrypted_payload');
if (!hasEncryptedPayloadPH) {
this.db.exec(`
ALTER TABLE paste_history
ADD COLUMN encrypted_payload TEXT;
`);
}
const hasEncryptionIvPH = pasteHistoryInfo.some((col) => col.name === 'encryption_iv');
if (!hasEncryptionIvPH) {
this.db.exec(`
ALTER TABLE paste_history
ADD COLUMN encryption_iv TEXT;
`);
}
const hasEncryptionTagPH = pasteHistoryInfo.some((col) => col.name === 'encryption_tag');
if (!hasEncryptionTagPH) {
this.db.exec(`
ALTER TABLE paste_history
ADD COLUMN encryption_tag TEXT;
`);
}
const hasEncryptionVersionPH = pasteHistoryInfo.some(
(col) => col.name === 'encryption_version'
);
if (!hasEncryptionVersionPH) {
this.db.exec(`
ALTER TABLE paste_history
ADD COLUMN encryption_version INTEGER DEFAULT 1;
`);
}
}
/**
* Get the underlying database connection
*/
getConnection(): Database.Database {
return this.db;
}
/**
* Close the database connection
*/
close(): void {
this.db.close();
}
/**
* Begin a transaction
*/
beginTransaction(): void {
this.db.exec('BEGIN TRANSACTION');
}
/**
* Commit a transaction
*/
commit(): void {
this.db.exec('COMMIT');
}
/**
* Rollback a transaction
*/
rollback(): void {
this.db.exec('ROLLBACK');
}
/**
* Execute a function within a transaction
*/
transaction<T>(fn: () => T): T {
try {
this.beginTransaction();
const result = fn();
this.commit();
return result;
} catch (error) {
this.rollback();
throw error;
}
}
/**
* Get encryption key used for clipboard payloads
*/
getEncryptionKey(): Buffer {
return this.encryptionKey;
}
/**
* Get path to the encryption key file (if applicable)
*/
getEncryptionKeyPath(): string | null {
return this.keyPath;
}
private loadOrCreateKey(path: string): Buffer {
if (!existsSync(path)) {
const key = randomBytes(32);
writeFileSync(path, key.toString('base64'), { mode: 0o600 });
try {
chmodSync(path, 0o600);
} catch {
// Ignore chmod errors (e.g., on Windows or restricted filesystems)
}
return key;
}
const raw = readFileSync(path, 'utf-8').trim();
if (!raw) {
throw new Error(`Encryption key at ${path} is empty`);
}
let key: Buffer;
try {
key = Buffer.from(raw, 'base64');
} catch (error) {
throw new Error(`Failed to parse encryption key at ${path}: ${error}`);
}
if (key.length !== 32) {
throw new Error(`Encryption key at ${path} must be 32 bytes when decoded`);
}
try {
chmodSync(path, 0o600);
} catch {
// Ignore chmod errors (e.g., on Windows or restricted filesystems)
}
return key;
}
}