435 lines
19 KiB
TypeScript
435 lines
19 KiB
TypeScript
import { eq } from 'drizzle-orm';
|
|
import { EnvBindings, AppError } from '@/app/config';
|
|
import { BackupRepository } from '@/shared/db/repositories/backupRepository';
|
|
import { encryptData, decryptData, encryptBackupFile } from '@/shared/utils/crypto';
|
|
import { BackupProvider, WebDavProvider, S3Provider, TelegramProvider, GoogleDriveProvider, OneDriveProvider, BaiduNetdiskProvider, DropboxProvider, EmailProvider } from '@/features/backup/providers';
|
|
import { decryptField } from '@/shared/db/db';
|
|
import { vault as vaultTable, backupProviders } from '@/shared/db/schema';
|
|
|
|
export class BackupService {
|
|
private repository: BackupRepository;
|
|
private env: EnvBindings;
|
|
private db: any;
|
|
|
|
constructor(env: EnvBindings, lang: string = 'en-US') {
|
|
this.env = env;
|
|
this.db = env.DB;
|
|
this.repository = new BackupRepository(env.DB);
|
|
// Normalize language (simple check: contains 'zh' -> 'zh-CN', else 'en-US')
|
|
this.lang = lang.toLowerCase().includes('zh') ? 'zh-CN' : 'en-US';
|
|
}
|
|
|
|
private lang: string;
|
|
|
|
private readonly MASK = '******';
|
|
private readonly SENSITIVE_FIELDS: Record<string, string[]> = {
|
|
webdav: ['password'],
|
|
s3: ['secretAccessKey'],
|
|
telegram: ['botToken'],
|
|
gdrive: ['refreshToken'],
|
|
onedrive: ['refreshToken'],
|
|
baidu: ['refreshToken'],
|
|
dropbox: ['refreshToken'],
|
|
email: ['smtpPassword']
|
|
};
|
|
|
|
private maskConfigForFrontend(type: string, config: any) {
|
|
const fields = this.SENSITIVE_FIELDS[type];
|
|
if (!fields) return;
|
|
for (const field of fields) {
|
|
if (config[field]) config[field] = this.MASK;
|
|
}
|
|
}
|
|
|
|
private mergeMaskedConfig(type: string, incomingConfig: any, currentConfigBase: any) {
|
|
const fields = this.SENSITIVE_FIELDS[type];
|
|
if (!fields) return;
|
|
for (const field of fields) {
|
|
if (incomingConfig[field] === this.MASK || incomingConfig[field] === undefined || incomingConfig[field] === null) {
|
|
incomingConfig[field] = currentConfigBase[field];
|
|
}
|
|
}
|
|
}
|
|
|
|
private async generateEncryptedPayload(key: string, backupPassword: string): Promise<string> {
|
|
const vaultResults = await this.db.select().from(vaultTable).all();
|
|
const accounts = (await Promise.all(vaultResults.map(async (row: any) => {
|
|
const secret = await decryptField(row.secret, key);
|
|
if (!secret) return null;
|
|
return {
|
|
service: row.service,
|
|
account: row.account,
|
|
category: row.category,
|
|
secret: secret,
|
|
digits: row.digits,
|
|
period: row.period
|
|
};
|
|
}))).filter(Boolean);
|
|
|
|
const exportPayload = {
|
|
version: "2.0",
|
|
app: "2fauth",
|
|
encrypted: true,
|
|
timestamp: new Date().toISOString(),
|
|
accounts
|
|
};
|
|
|
|
const userEncrypted = await encryptBackupFile(exportPayload, backupPassword);
|
|
return JSON.stringify({ ...exportPayload, data: userEncrypted, accounts: undefined });
|
|
}
|
|
|
|
private async getProvider(type: string, config: any, id?: number): Promise<BackupProvider> {
|
|
switch (type) {
|
|
case 'webdav':
|
|
return new WebDavProvider(config);
|
|
case 's3':
|
|
return new S3Provider(config);
|
|
case 'telegram':
|
|
return new TelegramProvider(config, this.db, id);
|
|
case 'gdrive':
|
|
return new GoogleDriveProvider(config, this.env);
|
|
case 'onedrive':
|
|
return new OneDriveProvider(config, this.env);
|
|
case 'baidu':
|
|
return new BaiduNetdiskProvider(config, this.env);
|
|
case 'dropbox':
|
|
return new DropboxProvider(config, this.env);
|
|
case 'email':
|
|
return new EmailProvider(config, this.db, id, this.lang);
|
|
default:
|
|
throw new AppError('provider_not_found', 400);
|
|
}
|
|
}
|
|
|
|
private async processConfigForStorage(type: string, config: any, key: string) {
|
|
const processed = { ...config };
|
|
if (type === 'webdav' && processed.password) {
|
|
processed.password = await encryptData(processed.password, key);
|
|
}
|
|
if (type === 's3' && processed.secretAccessKey) {
|
|
processed.secretAccessKey = await encryptData(processed.secretAccessKey, key);
|
|
}
|
|
if (type === 'telegram' && processed.botToken) {
|
|
processed.botToken = await encryptData(processed.botToken, key);
|
|
}
|
|
if (type === 'gdrive' && processed.refreshToken) {
|
|
processed.refreshToken = await encryptData(processed.refreshToken, key);
|
|
}
|
|
if (type === 'onedrive' && processed.refreshToken) {
|
|
processed.refreshToken = await encryptData(processed.refreshToken, key);
|
|
}
|
|
if (type === 'baidu' && processed.refreshToken) {
|
|
processed.refreshToken = await encryptData(processed.refreshToken, key);
|
|
}
|
|
if (type === 'dropbox' && processed.refreshToken) {
|
|
processed.refreshToken = await encryptData(processed.refreshToken, key);
|
|
}
|
|
if (type === 'email' && processed.smtpPassword) {
|
|
processed.smtpPassword = await encryptData(processed.smtpPassword, key);
|
|
}
|
|
return JSON.stringify(processed);
|
|
}
|
|
|
|
public async processConfigForUsage(type: string, configStr: string, key: string) {
|
|
const config = JSON.parse(configStr);
|
|
if (type === 'webdav' && config.password) {
|
|
config.password = await decryptData(config.password, key);
|
|
}
|
|
if (type === 's3' && config.secretAccessKey) {
|
|
config.secretAccessKey = await decryptData(config.secretAccessKey, key);
|
|
}
|
|
if (type === 'telegram' && config.botToken) {
|
|
config.botToken = await decryptData(config.botToken, key);
|
|
}
|
|
if (type === 'gdrive' && config.refreshToken) {
|
|
config.refreshToken = await decryptData(config.refreshToken, key);
|
|
}
|
|
if (type === 'onedrive' && config.refreshToken) {
|
|
config.refreshToken = await decryptData(config.refreshToken, key);
|
|
}
|
|
if (type === 'baidu' && config.refreshToken) {
|
|
config.refreshToken = await decryptData(config.refreshToken, key);
|
|
}
|
|
if (type === 'dropbox' && config.refreshToken) {
|
|
config.refreshToken = await decryptData(config.refreshToken, key);
|
|
}
|
|
if (type === 'email' && config.smtpPassword) {
|
|
config.smtpPassword = await decryptData(config.smtpPassword, key);
|
|
}
|
|
return config;
|
|
}
|
|
|
|
async getProvidersList() {
|
|
const results = await this.repository.findAllSettings();
|
|
const key = this.env.ENCRYPTION_KEY || this.env.JWT_SECRET;
|
|
|
|
return await Promise.all(results.map(async (row: any) => {
|
|
const config = await this.processConfigForUsage(row.type, row.config, key);
|
|
this.maskConfigForFrontend(row.type, config);
|
|
|
|
return {
|
|
...row,
|
|
config,
|
|
auto_backup: !!row.autoBackup,
|
|
auto_backup_password: !!row.autoBackupPassword,
|
|
auto_backup_retain: row.autoBackupRetain !== null ? row.autoBackupRetain : 30
|
|
};
|
|
}));
|
|
}
|
|
|
|
async addProvider(data: any): Promise<number> {
|
|
const { type, name, config, autoBackup, autoBackupPassword, autoBackupRetain } = data;
|
|
if (!type || !name || !config) throw new AppError('missing_fields', 400);
|
|
|
|
const key = this.env.ENCRYPTION_KEY || this.env.JWT_SECRET;
|
|
const encryptedConfig = await this.processConfigForStorage(type, config, key);
|
|
|
|
let encryptedAutoBackupPwd = null;
|
|
if (autoBackup && autoBackupPassword) {
|
|
if (autoBackupPassword.length < 12) throw new AppError('backup_password_length', 400);
|
|
encryptedAutoBackupPwd = JSON.stringify(await encryptData(autoBackupPassword, key));
|
|
} else if (autoBackup) {
|
|
throw new AppError('backup_password_required', 400);
|
|
}
|
|
|
|
const res = await this.db.insert(backupProviders).values({
|
|
type,
|
|
name,
|
|
config: encryptedConfig,
|
|
autoBackup: autoBackup ? true : false,
|
|
autoBackupPassword: encryptedAutoBackupPwd,
|
|
autoBackupRetain: autoBackupRetain !== undefined ? parseInt(autoBackupRetain, 10) : 30,
|
|
createdAt: Date.now(),
|
|
updatedAt: Date.now()
|
|
}).returning({ id: backupProviders.id });
|
|
|
|
return res[0].id;
|
|
}
|
|
|
|
async updateProvider(id: number, data: any) {
|
|
const { name, config, type, autoBackup, autoBackupPassword, autoBackupRetain } = data;
|
|
const key = this.env.ENCRYPTION_KEY || this.env.JWT_SECRET;
|
|
|
|
const currentProvider = await this.db.select().from(backupProviders).where(eq(backupProviders.id, id)).get();
|
|
if (currentProvider) {
|
|
const currentConfigBase = await this.processConfigForUsage(currentProvider.type, currentProvider.config, key);
|
|
this.mergeMaskedConfig(type, config, currentConfigBase);
|
|
}
|
|
|
|
const encryptedConfig = await this.processConfigForStorage(type, config, key);
|
|
|
|
const current = await this.db.select({ autoBackupPassword: backupProviders.autoBackupPassword }).from(backupProviders).where(eq(backupProviders.id, id)).get();
|
|
|
|
let finalAutoPwd = current?.autoBackupPassword;
|
|
if (autoBackupPassword) {
|
|
if (autoBackupPassword.length < 12) throw new AppError('backup_password_length', 400);
|
|
finalAutoPwd = JSON.stringify(await encryptData(autoBackupPassword, key));
|
|
} else if (autoBackup && !finalAutoPwd) {
|
|
throw new AppError('backup_password_required', 400);
|
|
}
|
|
|
|
await this.db.update(backupProviders).set({
|
|
name,
|
|
config: encryptedConfig,
|
|
autoBackup: autoBackup ? true : false,
|
|
autoBackupPassword: finalAutoPwd,
|
|
autoBackupRetain: autoBackupRetain !== undefined ? parseInt(autoBackupRetain, 10) : 30,
|
|
updatedAt: Date.now()
|
|
}).where(eq(backupProviders.id, id)).run();
|
|
}
|
|
|
|
async deleteProvider(id: number) {
|
|
await this.db.delete(backupProviders).where(eq(backupProviders.id, id)).run();
|
|
}
|
|
|
|
async testConnection(type: string, config: any, id?: number) {
|
|
const key = this.env.ENCRYPTION_KEY || this.env.JWT_SECRET;
|
|
|
|
if (id) {
|
|
const currentProvider = await this.db.select().from(backupProviders).where(eq(backupProviders.id, id)).get();
|
|
if (currentProvider) {
|
|
const currentConfigBase = await this.processConfigForUsage(currentProvider.type, currentProvider.config, key);
|
|
this.mergeMaskedConfig(type, config, currentConfigBase);
|
|
}
|
|
}
|
|
|
|
try {
|
|
const provider = await this.getProvider(type, config, id);
|
|
await provider.testConnection();
|
|
} catch (e: any) {
|
|
if (e.message === 'oauth_token_revoked' || e.message?.includes('oauth_token_revoked')) {
|
|
throw new AppError('oauth_token_revoked', 401);
|
|
}
|
|
throw new AppError(`connection_failed: ${e.message}`, 400);
|
|
}
|
|
}
|
|
|
|
async executeManualBackup(id: number, data: any) {
|
|
const { filename, content, password } = data;
|
|
let finalFilename: string;
|
|
let finalContent: string;
|
|
const key = this.env.ENCRYPTION_KEY || this.env.JWT_SECRET;
|
|
|
|
if (filename && content) {
|
|
finalFilename = filename;
|
|
finalContent = content;
|
|
} else if (password !== undefined) {
|
|
let backupPassword = password;
|
|
const providerRow = await this.db.select({ autoBackupPassword: backupProviders.autoBackupPassword }).from(backupProviders).where(eq(backupProviders.id, id)).get();
|
|
|
|
if (password === '') {
|
|
if (!providerRow || !providerRow.autoBackupPassword) {
|
|
throw new AppError('manual_backup_password_needed', 400);
|
|
}
|
|
backupPassword = await decryptData(JSON.parse(providerRow.autoBackupPassword), key);
|
|
}
|
|
|
|
if (!backupPassword || backupPassword.length < 12) {
|
|
throw new AppError('backup_password_length', 400);
|
|
}
|
|
|
|
finalFilename = `2fa-backup-manual-${new Date().toISOString().replace(/[:.]/g, '-')}.json`;
|
|
finalContent = await this.generateEncryptedPayload(key, backupPassword);
|
|
} else {
|
|
throw new AppError('missing_fields', 400);
|
|
}
|
|
|
|
const providerRow = await this.db.select().from(backupProviders).where(eq(backupProviders.id, id)).get();
|
|
if (!providerRow) throw new AppError('provider_not_found', 404);
|
|
|
|
const configObj = await this.processConfigForUsage(providerRow.type, providerRow.config, key);
|
|
const provider = await this.getProvider(providerRow.type, configObj, providerRow.id);
|
|
|
|
try {
|
|
await provider.uploadBackup(finalFilename, finalContent);
|
|
await this.db.update(backupProviders).set({
|
|
lastBackupAt: Date.now(),
|
|
lastBackupStatus: 'success'
|
|
}).where(eq(backupProviders.id, id)).run();
|
|
} catch (e: any) {
|
|
await this.db.update(backupProviders).set({ lastBackupStatus: 'failed' }).where(eq(backupProviders.id, id)).run();
|
|
if (e.message === 'oauth_token_revoked' || e.message?.includes('oauth_token_revoked')) {
|
|
throw new AppError('oauth_token_revoked', 401);
|
|
}
|
|
throw new AppError(`backup_failed: ${e.message}`, 500);
|
|
}
|
|
}
|
|
|
|
async getFiles(id: number) {
|
|
const providerRow = await this.db.select().from(backupProviders).where(eq(backupProviders.id, id)).get();
|
|
if (!providerRow) throw new AppError('provider_not_found', 404);
|
|
|
|
const key = this.env.ENCRYPTION_KEY || this.env.JWT_SECRET;
|
|
const config = await this.processConfigForUsage(providerRow.type, providerRow.config, key);
|
|
const provider = await this.getProvider(providerRow.type, config, providerRow.id);
|
|
|
|
try {
|
|
return await provider.listBackups();
|
|
} catch (e: any) {
|
|
if (e.message === 'oauth_token_revoked' || e.message?.includes('oauth_token_revoked')) {
|
|
throw new AppError('oauth_token_revoked', 401);
|
|
}
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
async downloadFile(id: number, filename: string) {
|
|
if (!filename) throw new AppError('filename_required', 400);
|
|
|
|
const providerRow = await this.db.select().from(backupProviders).where(eq(backupProviders.id, id)).get();
|
|
if (!providerRow) throw new AppError('provider_not_found', 404);
|
|
|
|
const key = this.env.ENCRYPTION_KEY || this.env.JWT_SECRET;
|
|
const config = await this.processConfigForUsage(providerRow.type, providerRow.config, key);
|
|
const provider = await this.getProvider(providerRow.type, config, providerRow.id);
|
|
|
|
try {
|
|
return await provider.downloadBackup(filename);
|
|
} catch (e: any) {
|
|
if (e.message === 'oauth_token_revoked' || e.message?.includes('oauth_token_revoked')) {
|
|
throw new AppError('oauth_token_revoked', 401);
|
|
}
|
|
if (e.message === 'email_download_not_supported') {
|
|
throw new AppError('email_download_not_supported', 400);
|
|
}
|
|
throw new AppError(`download_failed: ${e.message}`, 500);
|
|
}
|
|
}
|
|
|
|
async deleteFile(id: number, filename: string) {
|
|
if (!filename) throw new AppError('filename_required', 400);
|
|
|
|
const providerRow = await this.db.select().from(backupProviders).where(eq(backupProviders.id, id)).get();
|
|
if (!providerRow) throw new AppError('provider_not_found', 404);
|
|
|
|
const key = this.env.ENCRYPTION_KEY || this.env.JWT_SECRET;
|
|
const config = await this.processConfigForUsage(providerRow.type, providerRow.config, key);
|
|
const provider = await this.getProvider(providerRow.type, config, providerRow.id);
|
|
|
|
try {
|
|
await provider.deleteBackup(filename);
|
|
} catch (e: any) {
|
|
throw new AppError(`delete_failed: ${e.message}`, 500);
|
|
}
|
|
}
|
|
|
|
async handleScheduledBackup() {
|
|
console.log('[Backup] Starting scheduled backup...');
|
|
const providers = await this.db.select().from(backupProviders).all();
|
|
if (!providers || providers.length === 0) {
|
|
console.log('[Backup] No backup providers configured.');
|
|
return;
|
|
}
|
|
|
|
const key = this.env.ENCRYPTION_KEY || this.env.JWT_SECRET;
|
|
const filename = `2fa-backup-auto-${new Date().toISOString().replace(/[:.]/g, '-')}.json`;
|
|
|
|
for (const row of providers) {
|
|
if (!row.autoBackup || !row.autoBackupPassword) continue;
|
|
|
|
try {
|
|
const backupPassword = await decryptData(JSON.parse(row.autoBackupPassword), key);
|
|
const fileContent = await this.generateEncryptedPayload(key, backupPassword);
|
|
|
|
const config = await this.processConfigForUsage(row.type as string, row.config as string, key);
|
|
const provider = await this.getProvider(row.type as string, config, row.id);
|
|
|
|
await provider.uploadBackup(filename, fileContent);
|
|
|
|
await this.db.update(backupProviders).set({
|
|
lastBackupAt: Date.now(),
|
|
lastBackupStatus: 'success'
|
|
}).where(eq(backupProviders.id, row.id)).run();
|
|
|
|
console.log(`[Backup] Successfully backed up to ${row.name}`);
|
|
|
|
const retainCount = row.autoBackupRetain !== null ? row.autoBackupRetain : 30;
|
|
if (retainCount > 0) {
|
|
try {
|
|
const files = await provider.listBackups();
|
|
const autoFiles = files.filter((f: any) => f.filename.startsWith('2fa-backup-auto-'))
|
|
.sort((a: any, b: any) => b.filename.localeCompare(a.filename));
|
|
|
|
if (autoFiles.length > retainCount) {
|
|
const filesToDelete = autoFiles.slice(retainCount);
|
|
for (const fileToDelete of filesToDelete) {
|
|
try {
|
|
await provider.deleteBackup(fileToDelete.filename);
|
|
console.log(`[Backup Prune] Deleted old auto-backup: ${fileToDelete.filename}`);
|
|
} catch (delErr: any) {
|
|
console.error(`[Backup Prune] Failed to delete ${fileToDelete.filename}: ${delErr.message}`);
|
|
}
|
|
}
|
|
}
|
|
} catch (listErr: any) {
|
|
console.error(`[Backup Prune] Failed to list backups for pruning: ${listErr.message}`);
|
|
}
|
|
}
|
|
} catch (e: any) {
|
|
console.error(`[Backup] Failed to backup to ${row.name}: ${e.message}`);
|
|
await this.db.update(backupProviders).set({ lastBackupStatus: 'failed' }).where(eq(backupProviders.id, row.id)).run();
|
|
}
|
|
}
|
|
}
|
|
}
|