clipboard-manager.tsā¢7.36 kB
import { randomBytes, createCipheriv, createDecipheriv } from 'crypto';
import { DatabaseManager } from './database.js';
export interface ClipboardData {
content: string;
sourceFile: string;
startLine: number;
endLine: number;
operationType: 'copy' | 'cut';
cutSourceOriginalContent?: string; // Only for cut operations - stores original file state
}
export interface ClipboardContent extends ClipboardData {
copiedAt: number;
}
/**
* Manages clipboard operations for sessions
* Provides storage and retrieval of copied/cut code blocks
*/
export class ClipboardManager {
private dbManager: DatabaseManager;
private readonly MAX_CONTENT_SIZE = 10 * 1024 * 1024; // 10MB limit
private readonly ENCRYPTION_VERSION = 1;
constructor(dbManager: DatabaseManager) {
this.dbManager = dbManager;
}
/**
* Store content in the clipboard for a session
* Overwrites any existing clipboard content for this session
* @param sessionId - The session ID
* @param data - Clipboard data to store (content, source metadata, operation type)
* @throws Error if content exceeds maximum size limit
*/
setClipboard(sessionId: string, data: ClipboardData): void {
// Validate session ID
if (!sessionId || typeof sessionId !== 'string') {
throw new Error('Invalid session ID');
}
// Validate content size
const contentSize = Buffer.byteLength(data.content, 'utf-8');
if (contentSize > this.MAX_CONTENT_SIZE) {
throw new Error(
`Clipboard content (${(contentSize / 1024 / 1024).toFixed(2)}MB) exceeds maximum size of ${this.MAX_CONTENT_SIZE / 1024 / 1024}MB`
);
}
const db = this.dbManager.getConnection();
const timestamp = Date.now();
const { ciphertext, iv, authTag } = this.encryptPayload({
content: data.content,
sourceFile: data.sourceFile,
startLine: data.startLine,
endLine: data.endLine,
operationType: data.operationType,
cutSourceOriginalContent: data.cutSourceOriginalContent,
});
// Use INSERT OR REPLACE to handle both insert and update cases
db.prepare(
`INSERT OR REPLACE INTO clipboard_buffer
(session_id, content, source_file, start_line, end_line, copied_at, operation_type, cut_source_original_content, encrypted_payload, encryption_iv, encryption_tag, encryption_version)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
).run(
sessionId,
'[encrypted]',
'[encrypted]',
0,
0,
timestamp,
data.operationType,
null,
ciphertext,
iv,
authTag,
this.ENCRYPTION_VERSION
);
}
/**
* Retrieve clipboard content for a session
* @param sessionId - The session ID
* @returns Clipboard content or null if empty
*/
getClipboard(sessionId: string): ClipboardContent | null {
const db = this.dbManager.getConnection();
const row = db
.prepare(
`SELECT content, source_file, start_line, end_line, copied_at, operation_type, cut_source_original_content,
encrypted_payload, encryption_iv, encryption_tag, encryption_version
FROM clipboard_buffer WHERE session_id = ?`
)
.get(sessionId) as
| {
content: string;
source_file: string;
start_line: number;
end_line: number;
copied_at: number;
operation_type: 'copy' | 'cut';
cut_source_original_content: string | null;
encrypted_payload: string | null;
encryption_iv: string | null;
encryption_tag: string | null;
encryption_version: number | null;
}
| undefined;
if (!row) {
return null;
}
if (row.encrypted_payload && row.encryption_iv && row.encryption_tag) {
const payload = this.decryptPayload({
ciphertext: row.encrypted_payload,
iv: row.encryption_iv,
authTag: row.encryption_tag,
version: row.encryption_version ?? this.ENCRYPTION_VERSION,
});
return {
content: payload.content,
sourceFile: payload.sourceFile,
startLine: payload.startLine,
endLine: payload.endLine,
copiedAt: row.copied_at,
operationType: payload.operationType,
cutSourceOriginalContent: payload.cutSourceOriginalContent,
};
}
// Fallback for legacy plaintext rows
return {
content: row.content,
sourceFile: row.source_file,
startLine: row.start_line,
endLine: row.end_line,
copiedAt: row.copied_at,
operationType: row.operation_type,
cutSourceOriginalContent: row.cut_source_original_content || undefined,
};
}
/**
* Clear clipboard content for a session
* @param sessionId - The session ID
*/
clearClipboard(sessionId: string): void {
const db = this.dbManager.getConnection();
db.prepare('DELETE FROM clipboard_buffer WHERE session_id = ?').run(sessionId);
}
/**
* Check if a session has clipboard content
* @param sessionId - The session ID
* @returns true if clipboard has content, false otherwise
*/
hasContent(sessionId: string): boolean {
const db = this.dbManager.getConnection();
const result = db
.prepare('SELECT COUNT(*) as count FROM clipboard_buffer WHERE session_id = ?')
.get(sessionId) as { count: number };
return result.count > 0;
}
/**
* Get clipboard content size in bytes
* @param sessionId - The session ID
* @returns Content size in bytes, or 0 if empty
*/
getContentSize(sessionId: string): number {
const clipboard = this.getClipboard(sessionId);
if (!clipboard) {
return 0;
}
return Buffer.byteLength(clipboard.content, 'utf-8');
}
private encryptPayload(payload: {
content: string;
sourceFile: string;
startLine: number;
endLine: number;
operationType: 'copy' | 'cut';
cutSourceOriginalContent?: string;
}): { ciphertext: string; iv: string; authTag: string } {
const key = this.dbManager.getEncryptionKey();
const iv = randomBytes(12);
const cipher = createCipheriv('aes-256-gcm', key, iv);
const plaintext = JSON.stringify(payload);
const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
const authTag = cipher.getAuthTag();
return {
ciphertext: encrypted.toString('base64'),
iv: iv.toString('base64'),
authTag: authTag.toString('base64'),
};
}
private decryptPayload(params: {
ciphertext: string;
iv: string;
authTag: string;
version: number;
}): {
content: string;
sourceFile: string;
startLine: number;
endLine: number;
operationType: 'copy' | 'cut';
cutSourceOriginalContent?: string;
} {
if (params.version !== this.ENCRYPTION_VERSION) {
throw new Error(`Unsupported encryption version: ${params.version}`);
}
const key = this.dbManager.getEncryptionKey();
const iv = Buffer.from(params.iv, 'base64');
const authTag = Buffer.from(params.authTag, 'base64');
const encrypted = Buffer.from(params.ciphertext, 'base64');
const decipher = createDecipheriv('aes-256-gcm', key, iv);
decipher.setAuthTag(authTag);
const decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()]);
return JSON.parse(decrypted.toString('utf8'));
}
}