UNPKG

@smartsamurai/krapi-sdk

Version:

KRAPI TypeScript SDK - Easy-to-use client SDK for connecting to self-hosted KRAPI servers (like Appwrite SDK)

673 lines (591 loc) 18.7 kB
/** * File-Based Backup Backend * * Implements BackupBackend interface using file-based storage. * This is the default backend for backward compatibility. * * @module services/backup/file-backup-backend */ import * as crypto from "crypto"; import * as fs from "fs/promises"; import * as path from "path"; import { promisify } from "util"; import * as zlib from "zlib"; import { DatabaseConnection, Logger } from "../../core"; import { KrapiError } from "../../core/krapi-error"; import { normalizeError } from "../../utils/error-handler"; import { BackupBackend, BackupSnapshot, BackupStats, } from "./backup-backend.interface"; const gzip = promisify(zlib.gzip); const gunzip = promisify(zlib.gunzip); /** * File-based backup backend implementation * * Stores backups as encrypted JSON files on the filesystem. */ export class FileBackupBackend implements BackupBackend { private backupsDir: string; private encryptionAlgorithm = "aes-256-gcm"; private keyDerivationIterations = 100000; private dbConnection: DatabaseConnection; private dataPath: string; /** * Create a new FileBackupBackend instance * * @param dbConnection - Database connection for metadata queries * @param logger - Logger instance * @param backupsDir - Backup directory path (default: data/backups) * @param dataPath - Data directory path (default: data) */ constructor( dbConnection: DatabaseConnection, logger: Logger = console, backupsDir?: string, dataPath?: string ) { this.dbConnection = dbConnection; this.logger = logger; this.backupsDir = backupsDir || path.join(process.cwd(), "data", "backups"); this.dataPath = dataPath || path.join(process.cwd(), "data"); } private logger: Logger; /** * Initialize backup directory */ private async ensureBackupDir(): Promise<void> { try { await fs.mkdir(this.backupsDir, { recursive: true }); } catch (error) { this.logger.error("Failed to create backup directory:", error); throw error; } } /** * Derive encryption key from password using PBKDF2 */ private async deriveKey(password: string, salt: Buffer): Promise<Buffer> { return new Promise((resolve, reject) => { crypto.pbkdf2( password, salt, this.keyDerivationIterations, 32, // 256 bits for AES-256 "sha256", (error, derivedKey) => { if (error) { reject(error); } else { resolve(derivedKey); } } ); }); } /** * Encrypt data using AES-256-GCM */ private async encryptData( data: Buffer, password: string ): Promise<{ encrypted: Buffer; iv: Buffer; salt: Buffer; tag: Buffer }> { const salt = crypto.randomBytes(32); const iv = crypto.randomBytes(16); const key = await this.deriveKey(password, salt); const cipher = crypto.createCipheriv( this.encryptionAlgorithm, key, iv ) as crypto.CipherGCM; const encrypted = Buffer.concat([cipher.update(data), cipher.final()]); const tag = cipher.getAuthTag(); return { encrypted, iv, salt, tag }; } /** * Decrypt data using AES-256-GCM */ private async decryptData( encrypted: Buffer, iv: Buffer, salt: Buffer, tag: Buffer, password: string ): Promise<Buffer> { const key = await this.deriveKey(password, salt); const decipher = crypto.createDecipheriv( this.encryptionAlgorithm, key, iv ) as crypto.DecipherGCM; decipher.setAuthTag(tag); return Buffer.concat([decipher.update(encrypted), decipher.final()]); } /** * Generate backup ID */ private generateBackupId(): string { return `backup_${Date.now()}_${crypto.randomBytes(8).toString("hex")}`; } /** * Initialize backup repository (creates directory) */ async initializeRepository(_password: string): Promise<void> { await this.ensureBackupDir(); // For file-based backend, initialization just ensures directory exists // Password is used per-backup, not for repository } /** * Create system backup */ async createSystemBackup(options: { description?: string; password: string; tags?: string[]; }): Promise<BackupSnapshot> { try { await this.ensureBackupDir(); const backupId = this.generateBackupId(); const timestamp = new Date().toISOString(); // Collect system data const systemData: Record<string, unknown> = { timestamp, version: "2.0.0", type: "system", description: options.description || `System backup ${timestamp}`, }; // Get main database path const mainDbPath = path.join(this.dataPath, "krapi_main.db"); // Read main database file try { const mainDbData = await fs.readFile(mainDbPath); systemData.main_database = mainDbData.toString("base64"); } catch (error) { this.logger.warn(`Could not read main database: ${error}`); systemData.main_database = null; } // Get all projects const projectsResult = await this.dbConnection.query( "SELECT id FROM projects WHERE is_active = 1" ); systemData.projects = (projectsResult.rows || [] as unknown[]).map( (row: unknown) => { const rowData = row as Record<string, unknown>; return rowData.id as string; } ); // Serialize system data const jsonData = JSON.stringify(systemData); const dataBuffer = Buffer.from(jsonData, "utf8"); // Compress data const compressed = await gzip(dataBuffer); // Encrypt data const { encrypted, iv, salt, tag } = await this.encryptData( compressed, options.password ); // Create backup file structure const backupFile = { metadata: { id: backupId, type: "system", created_at: timestamp, size: encrypted.length, encrypted: true, version: "2.0.0", description: options.description, tags: options.tags || ["system", "full"], }, data: encrypted.toString("base64"), iv: iv.toString("base64"), salt: salt.toString("base64"), tag: tag.toString("base64"), }; // Save backup file const backupFilePath = path.join(this.backupsDir, `${backupId}.json`); await fs.writeFile( backupFilePath, JSON.stringify(backupFile, null, 2), "utf8" ); // Return snapshot const snapshot: BackupSnapshot = { id: backupId, snapshot_id: backupId, // For file-based, snapshot_id = id type: "system", created_at: timestamp, size: encrypted.length, encrypted: true, version: "2.0.0", tags: options.tags || ["system", "full"], file_count: 1, // Single file backup }; if (options.description !== undefined) { snapshot.description = options.description; } return snapshot; } catch (error) { this.logger.error("Failed to create system backup:", error); throw normalizeError(error, "INTERNAL_ERROR", { operation: "createSystemBackup", }); } } /** * Create project backup */ async createProjectBackup( projectId: string, options: { description?: string; password: string; includeFiles?: boolean; tags?: string[]; } ): Promise<BackupSnapshot> { try { if (!projectId) { throw KrapiError.validationError( "Project ID is required for project backup", "projectId" ); } await this.ensureBackupDir(); const backupId = this.generateBackupId(); const timestamp = new Date().toISOString(); // Collect project data const projectData: Record<string, unknown> = { project_id: projectId, timestamp, version: "2.0.0", type: "project", description: options.description || `Project backup ${timestamp}`, }; // Get project metadata from main database const projectResult = await this.dbConnection.query( "SELECT * FROM projects WHERE id = $1", [projectId] ); if (projectResult.rows.length === 0) { throw KrapiError.notFound(`Project '${projectId}' not found`, { projectId, operation: "createProjectBackup", }); } projectData.project = projectResult.rows[0]; // Get project database path const projectDbPath = path.join( this.dataPath, "projects", `project_${projectId}.db` ); // Read project database file try { const dbData = await fs.readFile(projectDbPath); projectData.database = dbData.toString("base64"); } catch (error) { this.logger.warn(`Could not read project database: ${error}`); projectData.database = null; } // Serialize project data const jsonData = JSON.stringify(projectData); const dataBuffer = Buffer.from(jsonData, "utf8"); // Compress data const compressed = await gzip(dataBuffer); // Encrypt data const { encrypted, iv, salt, tag } = await this.encryptData( compressed, options.password ); // Create backup file structure const backupFile = { metadata: { id: backupId, project_id: projectId, type: "project", created_at: timestamp, size: encrypted.length, encrypted: true, version: "2.0.0", description: options.description, tags: options.tags || ["project", projectId], }, data: encrypted.toString("base64"), iv: iv.toString("base64"), salt: salt.toString("base64"), tag: tag.toString("base64"), }; // Save backup file const backupFilePath = path.join(this.backupsDir, `${backupId}.json`); await fs.writeFile( backupFilePath, JSON.stringify(backupFile, null, 2), "utf8" ); // Return snapshot const snapshot: BackupSnapshot = { id: backupId, snapshot_id: backupId, // For file-based, snapshot_id = id type: "project", project_id: projectId, created_at: timestamp, size: encrypted.length, encrypted: true, version: "2.0.0", tags: options.tags || ["project", projectId], file_count: 1, // Single file backup }; if (options.description !== undefined) { snapshot.description = options.description; } return snapshot; } catch (error) { this.logger.error("Failed to create project backup:", error); throw normalizeError(error, "INTERNAL_ERROR", { operation: "createProjectBackup", projectId, }); } } /** * List backups * * Note: For file-based backend, we query the database for metadata */ async listBackups( _password: string, options?: { projectId?: string; type?: "project" | "system"; limit?: number; } ): Promise<BackupSnapshot[]> { try { let query = "SELECT * FROM backups WHERE 1=1"; const params: unknown[] = []; let paramCount = 0; if (options?.projectId) { paramCount++; query += ` AND project_id = $${paramCount}`; params.push(options.projectId); } if (options?.type) { paramCount++; query += ` AND type = $${paramCount}`; params.push(options.type); } query += " ORDER BY created_at DESC"; if (options?.limit) { query += ` LIMIT ${options.limit}`; } const result = await this.dbConnection.query(query, params); return (result.rows || [] as unknown[]).map((row: unknown) => { const rowData = row as Record<string, unknown>; const snapshot: BackupSnapshot = { id: rowData.id as string, snapshot_id: rowData.id as string, // For file-based, snapshot_id = id type: rowData.type as "project" | "system", created_at: rowData.created_at as string, size: rowData.size as number, encrypted: (rowData.encrypted as boolean) || false, version: (rowData.version as string) || "2.0.0", file_count: 1, }; if (rowData.project_id !== undefined && rowData.project_id !== null) { snapshot.project_id = rowData.project_id as string; } if ( rowData.description !== undefined && rowData.description !== null ) { snapshot.description = rowData.description as string; } return snapshot; }); } catch (error) { this.logger.error("Failed to list backups:", error); // If backups table doesn't exist, return empty array if ( error instanceof Error && (error.message.includes("no such table") || error.message.includes("does not exist")) ) { return []; } throw error; } } /** * Restore backup */ async restoreBackup( snapshotId: string, password: string, targetPath: string, _options?: { include?: string[]; exclude?: string[]; } ): Promise<void> { try { // Get backup metadata from database const backupResult = await this.dbConnection.query( "SELECT * FROM backups WHERE id = $1", [snapshotId] ); if (backupResult.rows.length === 0) { throw KrapiError.notFound(`Backup '${snapshotId}' not found`, { snapshotId, operation: "restoreBackup", }); } const backup = backupResult.rows[0] as { file_path?: string; type?: string; [key: string]: unknown; }; if (!backup.file_path) { throw KrapiError.validationError( `Backup ${snapshotId} has no file path`, "file_path", snapshotId ); } // Read backup file const backupFilePath = backup.file_path; const backupFileContent = await fs.readFile(backupFilePath, "utf8"); const backupFile = JSON.parse(backupFileContent); // Decrypt data const encrypted = Buffer.from(backupFile.data, "base64"); const iv = Buffer.from(backupFile.iv, "base64"); const salt = Buffer.from(backupFile.salt, "base64"); const tag = Buffer.from(backupFile.tag, "base64"); const decrypted = await this.decryptData( encrypted, iv, salt, tag, password ); // Decompress data const decompressed = await gunzip(decrypted); const backupData = JSON.parse(decompressed.toString("utf8")); // Restore based on type if (backup.type === "project") { if (!backupData.project_id) { throw KrapiError.validationError( "Invalid backup: missing project_id", "project_id" ); } const projectId = backupData.project_id as string; // Restore project database if (backupData.database) { const projectDbPath = path.join( targetPath, `project_${projectId}.db` ); // Ensure target directory exists await fs.mkdir(targetPath, { recursive: true }); // Restore database file const dbData = Buffer.from(backupData.database, "base64"); await fs.writeFile(projectDbPath, dbData); this.logger.info(`Restored project database: ${projectId}`); } } else if (backup.type === "system") { // Restore main database if (backupData.main_database) { const mainDbPath = path.join(targetPath, "krapi_main.db"); // Ensure target directory exists await fs.mkdir(targetPath, { recursive: true }); // Restore database file const dbData = Buffer.from(backupData.main_database, "base64"); await fs.writeFile(mainDbPath, dbData); this.logger.info("Restored main database"); } } } catch (error) { this.logger.error("Failed to restore backup:", error); throw normalizeError(error, "INTERNAL_ERROR", { operation: "restoreBackup", snapshotId, }); } } /** * Delete backup */ async deleteBackup(snapshotId: string, _password: string): Promise<void> { try { // Get backup metadata const backupResult = await this.dbConnection.query( "SELECT file_path FROM backups WHERE id = $1", [snapshotId] ); if (backupResult.rows.length === 0) { throw KrapiError.notFound(`Backup '${snapshotId}' not found`, { snapshotId, operation: "deleteBackup", }); } const backup = backupResult.rows[0] as { file_path?: string; [key: string]: unknown; }; if (!backup.file_path || typeof backup.file_path !== "string") { throw KrapiError.validationError( `Backup ${snapshotId} has no file path`, "file_path", snapshotId ); } const filePath = backup.file_path; // Delete backup file try { await fs.unlink(filePath); } catch (error) { this.logger.warn(`Could not delete backup file: ${error}`); } // Note: Database metadata deletion is handled by BackupService this.logger.info(`Deleted backup file: ${snapshotId}`); } catch (error) { this.logger.error("Failed to delete backup:", error); throw error; } } /** * Get backup statistics */ async getBackupStats( snapshotId: string, _password: string ): Promise<BackupStats> { try { // Get backup metadata const backupResult = await this.dbConnection.query( "SELECT * FROM backups WHERE id = $1", [snapshotId] ); if (backupResult.rows.length === 0) { throw KrapiError.notFound(`Backup '${snapshotId}' not found`, { snapshotId, operation: "getBackupStats", }); } const backup = backupResult.rows[0] as { size?: number; [key: string]: unknown; }; const size = (backup.size as number) || 0; return { total_size: size, unique_size: size, // File-based doesn't have deduplication file_count: 1, }; } catch (error) { this.logger.error("Failed to get backup stats:", error); throw error; } } }