UNPKG

@smartsamurai/krapi-sdk

Version:

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

570 lines (519 loc) 17.6 kB
/** * Backup Service for BackendSDK * * Provides encrypted backup and restore functionality for KRAPI projects. * Supports: * - Individual project backups (encrypted) * - System-wide backups (encrypted) * - Backup versioning * - Secure backup storage * * Uses a BackupBackend abstraction to support different backup storage implementations * (file-based, Restic, etc.) */ import * as crypto from "crypto"; import * as path from "path"; import { DatabaseConnection, Logger } from "./core"; import { KrapiError } from "./core/krapi-error"; import { BackupBackend, BackupSnapshot } from "./services/backup/backup-backend.interface"; import { FileBackupBackend } from "./services/backup/file-backup-backend"; import { normalizeError } from "./utils/error-handler"; export interface BackupMetadata { id: string; project_id?: string; type: "project" | "system"; created_at: string; size: number; encrypted: boolean; version: string; description?: string; /** Restic snapshot ID (for Restic backend) */ snapshot_id?: string; /** Unique data size after deduplication */ unique_size?: number; /** Number of files in backup */ file_count?: number; /** Backup tags */ tags?: string[]; } export interface BackupOptions { projectId?: string; description?: string; password?: string; includeFiles?: boolean; compressionLevel?: number; } export interface RestoreOptions { password?: string; overwrite?: boolean; targetPath?: string; targetProjectId?: string; } /** * Backup Service for KRAPI * * Provides encrypted backup and restore functionality. * Supports project backups and system-wide backups with encryption. * * @class BackupService * @example * const backupService = new BackupService(dbConnection, logger); * const backup = await backupService.backupProject({ projectId: 'project-id', password: 'encryption-password' }); */ export class BackupService { private backend: BackupBackend; private dataPath: string; /** * Create a new BackupService instance * * @param dbConnection - Database connection * @param logger - Logger instance * @param backupBackend - Optional backup backend (defaults to FileBackupBackend) * @param backupsDir - Backup directory path (for FileBackupBackend, default: data/backups) * @param dataPath - Data directory path (default: data) */ constructor( private dbConnection: DatabaseConnection, private logger: Logger = console, backupBackend?: BackupBackend, backupsDir?: string, dataPath?: string ) { this.dataPath = dataPath || path.join(process.cwd(), "data"); // Use provided backend or create default file-based backend if (backupBackend) { this.backend = backupBackend; } else { this.backend = new FileBackupBackend( dbConnection, logger, backupsDir, this.dataPath ); } } /** * Convert BackupSnapshot to BackupMetadata */ private snapshotToMetadata(snapshot: BackupSnapshot): BackupMetadata { const metadata: BackupMetadata = { id: snapshot.id, type: snapshot.type, created_at: snapshot.created_at, size: snapshot.size, encrypted: snapshot.encrypted, version: snapshot.version, }; if (snapshot.project_id !== undefined) { metadata.project_id = snapshot.project_id; } if (snapshot.description !== undefined) { metadata.description = snapshot.description; } if (snapshot.snapshot_id !== undefined) { metadata.snapshot_id = snapshot.snapshot_id; } if (snapshot.unique_size !== undefined) { metadata.unique_size = snapshot.unique_size; } if (snapshot.file_count !== undefined) { metadata.file_count = snapshot.file_count; } if (snapshot.tags !== undefined) { metadata.tags = snapshot.tags; } return metadata; } /** * Save backup metadata to database */ private async saveBackupMetadata( snapshot: BackupSnapshot, filePath?: string ): Promise<void> { try { // For file-based backend, filePath is the actual file path // For Restic backend, filePath would be a virtual path like "restic://snapshot-id" const virtualPath = filePath || `restic://${snapshot.snapshot_id}`; await this.dbConnection.query( `INSERT INTO backups (id, project_id, type, created_at, size, encrypted, version, description, file_path, snapshot_id, unique_size, file_count) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)`, [ snapshot.id, snapshot.project_id || null, snapshot.type, snapshot.created_at, snapshot.size, snapshot.encrypted ? 1 : 0, // SQLite stores booleans as integers snapshot.version, snapshot.description || null, virtualPath, snapshot.snapshot_id || null, snapshot.unique_size || null, snapshot.file_count || null, ] ); } catch (error) { // If backups table doesn't exist or columns don't exist, just log warning this.logger.warn("Could not save backup metadata to database:", error); } } /** * Get backup metadata from database */ private async getBackupMetadata(backupId: string): Promise<BackupMetadata | null> { try { const result = await this.dbConnection.query( "SELECT * FROM backups WHERE id = $1", [backupId] ); if (result.rows.length === 0) { return null; } const row = result.rows[0] as Record<string, unknown>; const metadata: BackupMetadata = { id: row.id as string, type: row.type as "project" | "system", created_at: row.created_at as string, size: row.size as number, encrypted: (row.encrypted as number) === 1, version: (row.version as string) || "2.0.0", }; if (row.project_id !== undefined && row.project_id !== null) { metadata.project_id = row.project_id as string; } if (row.description !== undefined && row.description !== null) { metadata.description = row.description as string; } if (row.snapshot_id !== undefined && row.snapshot_id !== null) { metadata.snapshot_id = row.snapshot_id as string; } if (row.unique_size !== undefined && row.unique_size !== null) { metadata.unique_size = row.unique_size as number; } if (row.file_count !== undefined && row.file_count !== null) { metadata.file_count = row.file_count as number; } if (row.tags !== undefined && row.tags !== null) { metadata.tags = JSON.parse(row.tags as string); } return metadata; } catch (error) { this.logger.error("Failed to get backup metadata:", error); return null; } } /** * Delete backup metadata from database */ private async deleteBackupMetadata(backupId: string): Promise<void> { try { await this.dbConnection.query("DELETE FROM backups WHERE id = $1", [backupId]); } catch (error) { this.logger.warn("Could not delete backup metadata from database:", error); } } /** * Create encrypted backup of a project * * Creates an encrypted backup of a project's database and optionally files. * * @param options - Backup options * @param options.projectId - Project ID (required) * @param options.description - Backup description * @param options.password - Encryption password (generated if not provided) * @param options.includeFiles - Whether to include files in backup * @returns Backup metadata with password * @throws {Error} If project not found or backup fails * * @example * const backup = await backupService.backupProject({ * projectId: 'project-id', * description: 'Monthly backup', * password: 'secure-password' * }); */ async backupProject( options: BackupOptions ): Promise<BackupMetadata & { password: string }> { if (!options.projectId) { throw KrapiError.validationError( "Project ID is required for project backup", "projectId" ); } try { const password = options.password || this.generatePassword(); // Use backend to create backup const backupOptions: { password: string; description?: string; includeFiles?: boolean; tags: string[]; } = { password, includeFiles: options.includeFiles ?? true, tags: ["project", options.projectId], }; if (options.description !== undefined) { backupOptions.description = options.description; } const snapshot = await this.backend.createProjectBackup( options.projectId, backupOptions ); // Save metadata to database await this.saveBackupMetadata(snapshot); this.logger.info(`Created encrypted backup: ${snapshot.id}`); return { ...this.snapshotToMetadata(snapshot), password, } as BackupMetadata & { password: string }; } catch (error) { this.logger.error("Failed to create project backup:", error); throw normalizeError(error, "INTERNAL_ERROR", { operation: "backupProject", projectId: options.projectId, }); } } /** * Create system-wide backup (all projects + main database) * * @param options - Backup options * @param options.description - Backup description * @param options.password - Encryption password (generated if not provided) * @returns Backup metadata with password */ async backupSystem( options: BackupOptions = {} ): Promise<BackupMetadata & { password: string }> { try { const password = options.password || this.generatePassword(); // Use backend to create backup const backupOptions: { password: string; description?: string; tags: string[]; } = { password, tags: ["system", "full"], }; if (options.description !== undefined) { backupOptions.description = options.description; } const snapshot = await this.backend.createSystemBackup(backupOptions); // Save metadata to database await this.saveBackupMetadata(snapshot); this.logger.info(`Created encrypted system backup: ${snapshot.id}`); return { ...this.snapshotToMetadata(snapshot), password, } as BackupMetadata & { password: string }; } catch (error) { this.logger.error("Failed to create system backup:", error); throw normalizeError(error, "INTERNAL_ERROR", { operation: "backupSystem", }); } } /** * Restore project from encrypted backup * * @param backupId - Backup ID to restore * @param options - Restore options * @param options.password - Password required to restore encrypted backup * @param options.overwrite - Whether to overwrite existing project * @param options.targetPath - Target path for restoration (optional) * @param options.targetProjectId - Target project ID (optional) */ async restoreProject( backupId: string, options: RestoreOptions = {} ): Promise<void> { try { if (!options.password) { throw KrapiError.validationError( "Password is required to restore encrypted backup", "password" ); } // Get backup metadata const metadata = await this.getBackupMetadata(backupId); if (!metadata) { throw KrapiError.notFound(`Backup '${backupId}' not found`, { backupId, operation: "restoreProject", }); } // Determine target path let targetPath: string; if (options.targetPath) { targetPath = options.targetPath; } else if (metadata.type === "project") { const projectId = options.targetProjectId || metadata.project_id; if (!projectId) { throw KrapiError.validationError( "Project ID is required for project backup restoration", "projectId" ); } targetPath = path.join(this.dataPath, "projects"); } else { targetPath = this.dataPath; } // Use snapshot_id if available, otherwise use id const snapshotId = metadata.snapshot_id || metadata.id; // Restore using backend await this.backend.restoreBackup(snapshotId, options.password, targetPath); // For project backups, also restore project metadata in database if (metadata.type === "project" && metadata.project_id) { // Get project data from backup to restore metadata // Note: This is a simplified version - full restore would need to read backup file // For now, we rely on the backend to handle the actual restoration const projectId = options.targetProjectId || metadata.project_id; // Check if project exists and overwrite flag const existingProject = await this.dbConnection.query( "SELECT id FROM projects WHERE id = $1", [projectId] ); if (existingProject.rows.length > 0 && !options.overwrite) { throw KrapiError.conflict( `Project ${projectId} already exists. Use overwrite option to restore anyway.`, { resource: "projects", operation: "restoreProject", projectId, backupId, } ); } } this.logger.info(`Restored backup: ${backupId}`); } catch (error) { this.logger.error("Failed to restore backup:", error); throw normalizeError(error, "INTERNAL_ERROR", { operation: "restoreProject", backupId, }); } } /** * List all backups * * @param projectId - Optional project ID filter * @param type - Optional backup type filter * @returns Array of backup metadata */ async listBackups( projectId?: string, type?: "project" | "system" ): Promise<BackupMetadata[]> { try { // Use backend to list backups (requires password, but we'll use a dummy for listing) // Note: For file-based backend, this queries the database // For Restic backend, this would query the repository const password = "dummy"; // Backend implementations should handle listing without password const listOptions: { projectId?: string; type?: "project" | "system"; } = {}; if (projectId !== undefined) { listOptions.projectId = projectId; } if (type !== undefined) { listOptions.type = type; } const snapshots = await this.backend.listBackups(password, listOptions); return snapshots.map((snapshot) => this.snapshotToMetadata(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; } } /** * Get backup details * * @param backupId - Backup ID * @param password - Backup password (optional, for stats) * @returns Backup metadata */ async getBackup( backupId: string, password?: string ): Promise<BackupMetadata> { try { // Get metadata from database const metadata = await this.getBackupMetadata(backupId); if (!metadata) { throw KrapiError.notFound(`Backup '${backupId}' not found`, { backupId, operation: "getBackup", }); } // If password provided, get stats from backend if (password && metadata.snapshot_id) { try { const stats = await this.backend.getBackupStats( metadata.snapshot_id, password ); return { ...metadata, unique_size: stats.unique_size, file_count: stats.file_count, }; } catch (error) { this.logger.warn("Could not get backup stats:", error); } } return metadata; } catch (error) { this.logger.error("Failed to get backup:", error); throw error; } } /** * Delete backup * * @param backupId - Backup ID to delete * @param password - Backup password (required for some backends) */ async deleteBackup(backupId: string, password?: string): Promise<void> { try { // Get metadata to find snapshot_id const metadata = await this.getBackupMetadata(backupId); if (!metadata) { throw KrapiError.notFound(`Backup '${backupId}' not found`, { backupId, operation: "deleteBackup", }); } // Use snapshot_id if available, otherwise use id const snapshotId = metadata.snapshot_id || metadata.id; // Delete from backend (password may be required) const deletePassword = password || "dummy"; // Some backends may not require password await this.backend.deleteBackup(snapshotId, deletePassword); // Delete metadata from database await this.deleteBackupMetadata(backupId); this.logger.info(`Deleted backup: ${backupId}`); } catch (error) { this.logger.error("Failed to delete backup:", error); throw error; } } /** * Generate random password */ private generatePassword(): string { return crypto.randomBytes(32).toString("hex"); } }