@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
text/typescript
/**
* 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;
}
}
}