diff --git a/README.md b/README.md index 783ada5..7591eda 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ - 🛡️ **TOTP 密钥分析与生成工具** ### 🔄 企业级数据灾备 -- **WebDAV / S3 自动备份**:连接你的Nextcloud、阿里云 OSS 或 AWS S3。 +- **WebDAV / S3 / Telegram 自动备份**:连接你的Nextcloud、阿里云 OSS 或 AWS S3,或者 Telegram Bot。 - 自动化定时任务(Cron Triggers)实现无感数据双重灾备。 --- diff --git a/backend/schema.sql b/backend/schema.sql index 4d66d62..3fdaf06 100644 --- a/backend/schema.sql +++ b/backend/schema.sql @@ -17,7 +17,7 @@ CREATE TABLE IF NOT EXISTS vault ( -- 云端备份源配置表 CREATE TABLE IF NOT EXISTS backup_providers ( id INTEGER PRIMARY KEY AUTOINCREMENT, - type TEXT NOT NULL, -- 类型: 'webdav', 's3' + type TEXT NOT NULL, -- 类型: 'webdav', 's3', 'telegram' name TEXT NOT NULL, -- 显示名称 is_enabled BOOLEAN DEFAULT 1, -- 启用状态 config TEXT NOT NULL, -- 配置 JSON (敏感字段加密) @@ -30,11 +30,23 @@ CREATE TABLE IF NOT EXISTS backup_providers ( updated_at INTEGER ); +-- Telegram 备份历史记录表 +CREATE TABLE IF NOT EXISTS backup_telegram_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + provider_id INTEGER NOT NULL, + filename TEXT NOT NULL, + file_id TEXT NOT NULL, + message_id INTEGER NOT NULL, + size INTEGER NOT NULL, + created_at INTEGER NOT NULL +); + -- 索引 DROP INDEX IF EXISTS idx_vault_service; CREATE INDEX IF NOT EXISTS idx_vault_created_at ON vault(created_at DESC); CREATE INDEX IF NOT EXISTS idx_backup_providers_type ON backup_providers(type); CREATE INDEX IF NOT EXISTS idx_vault_service_created_at ON vault(service, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_backup_telegram_history_provider_id ON backup_telegram_history(provider_id, created_at DESC); -- Remove any existing duplicates before enforcing unique constraint; keep the earliest DELETE FROM vault diff --git a/backend/src/features/backup/backupRoutes.ts b/backend/src/features/backup/backupRoutes.ts index 6b86b61..4f51c7f 100644 --- a/backend/src/features/backup/backupRoutes.ts +++ b/backend/src/features/backup/backupRoutes.ts @@ -65,6 +65,14 @@ backups.post('/providers/:id/download', async (c) => { return c.json({ success: true, content }); }); +backups.post('/providers/:id/files/delete', async (c) => { + const service = new BackupService(c.env); + const id = Number(c.req.param('id')); + const { filename } = await c.req.json(); + await service.deleteFile(id, filename); + return c.json({ success: true }); +}); + export async function handleScheduledBackup(env: EnvBindings) { const service = new BackupService(env); await service.handleScheduledBackup(); diff --git a/backend/src/features/backup/backupService.ts b/backend/src/features/backup/backupService.ts index 37fe9e0..d0fbd17 100644 --- a/backend/src/features/backup/backupService.ts +++ b/backend/src/features/backup/backupService.ts @@ -2,7 +2,7 @@ import { eq, desc } 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 } from '@/features/backup/providers'; +import { BackupProvider, WebDavProvider, S3Provider, TelegramProvider } from '@/features/backup/providers'; import { createDb, decryptField } from '@/shared/db/db'; import { vault as vaultTable, backupProviders } from '@/shared/db/schema'; @@ -17,12 +17,14 @@ export class BackupService { this.repository = new BackupRepository(env.DB); } - private async getProvider(type: string, config: any): Promise { + private async getProvider(type: string, config: any, id?: number): Promise { switch (type) { case 'webdav': return new WebDavProvider(config); case 's3': return new S3Provider(config); + case 'telegram': + return new TelegramProvider(config, this.db, id); default: throw new AppError('provider_not_found', 400); } @@ -36,6 +38,9 @@ export class BackupService { if (type === 's3' && processed.secretAccessKey) { processed.secretAccessKey = await encryptData(processed.secretAccessKey, key); } + if (type === 'telegram' && processed.botToken) { + processed.botToken = await encryptData(processed.botToken, key); + } return JSON.stringify(processed); } @@ -47,6 +52,9 @@ export class BackupService { if (type === 's3' && config.secretAccessKey) { config.secretAccessKey = await decryptData(config.secretAccessKey, key); } + if (type === 'telegram' && config.botToken) { + config.botToken = await decryptData(config.botToken, key); + } return config; } @@ -64,6 +72,9 @@ export class BackupService { if (row.type === 's3' && config.secretAccessKey) { config.secretAccessKey = MASK; } + if (row.type === 'telegram' && config.botToken) { + config.botToken = MASK; + } return { ...row, @@ -118,6 +129,9 @@ export class BackupService { if (type === 's3' && (config.secretAccessKey === MASK || !config.secretAccessKey)) { config.secretAccessKey = currentConfigBase.secretAccessKey; } + if (type === 'telegram' && (config.botToken === MASK || !config.botToken)) { + config.botToken = currentConfigBase.botToken; + } } const encryptedConfig = await this.processConfigForStorage(type, config, key); @@ -160,11 +174,14 @@ export class BackupService { if (type === 's3' && config.secretAccessKey === MASK) { config.secretAccessKey = currentConfigBase.secretAccessKey; } + if (type === 'telegram' && config.botToken === MASK) { + config.botToken = currentConfigBase.botToken; + } } } try { - const provider = await this.getProvider(type, config); + const provider = await this.getProvider(type, config, id); await provider.testConnection(); } catch (e: any) { throw new AppError(`connection_failed: ${e.message}`, 400); @@ -230,7 +247,7 @@ export class BackupService { 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); + const provider = await this.getProvider(providerRow.type, configObj, providerRow.id); try { await provider.uploadBackup(finalFilename, finalContent); @@ -250,7 +267,7 @@ export class BackupService { 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); + const provider = await this.getProvider(providerRow.type, config, providerRow.id); return await provider.listBackups(); } @@ -263,7 +280,7 @@ export class BackupService { 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); + const provider = await this.getProvider(providerRow.type, config, providerRow.id); try { return await provider.downloadBackup(filename); @@ -272,6 +289,23 @@ export class BackupService { } } + 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(); @@ -315,7 +349,7 @@ export class BackupService { const fileContent = JSON.stringify({ ...exportPayload, data: userEncrypted, accounts: undefined }); const config = await this.processConfigForUsage(row.type as string, row.config as string, key); - const provider = await this.getProvider(row.type as string, config); + const provider = await this.getProvider(row.type as string, config, row.id); await provider.uploadBackup(filename, fileContent); diff --git a/backend/src/features/backup/providers/index.ts b/backend/src/features/backup/providers/index.ts index a9a98f1..7ff548b 100644 --- a/backend/src/features/backup/providers/index.ts +++ b/backend/src/features/backup/providers/index.ts @@ -1,3 +1,4 @@ export * from '@/features/backup/providers/backupProvider'; export * from '@/features/backup/providers/webDavProvider'; -export * from '@/features/backup/providers/s3Provider'; \ No newline at end of file +export * from '@/features/backup/providers/s3Provider'; +export * from '@/features/backup/providers/telegramProvider'; \ No newline at end of file diff --git a/backend/src/features/backup/providers/s3Provider.ts b/backend/src/features/backup/providers/s3Provider.ts index 370b996..5fc754f 100644 --- a/backend/src/features/backup/providers/s3Provider.ts +++ b/backend/src/features/backup/providers/s3Provider.ts @@ -9,7 +9,7 @@ export class S3Provider implements BackupProvider { constructor(config: any) { if (!config.endpoint || !config.accessKeyId || !config.secretAccessKey || !config.bucket) { - throw new Error('S3 configuration incomplete'); + throw new Error('s3_config_incomplete'); } this.client = new AwsClient({ @@ -42,7 +42,7 @@ export class S3Provider implements BackupProvider { const url = this.getUrl('', { 'list-type': '2', 'max-keys': '1' }); const res = await this.client.fetch(url); if (!res.ok) { - throw new Error(`S3 Error: ${res.status} ${res.statusText}`); + throw new Error(`s3_api_error: ${res.status} ${res.statusText}`); } return true; } @@ -50,7 +50,7 @@ export class S3Provider implements BackupProvider { async listBackups(): Promise { const url = this.getUrl('', { 'list-type': '2', 'prefix': this.prefix }); const res = await this.client.fetch(url); - if (!res.ok) throw new Error(`Failed to list S3 objects: ${res.status}`); + if (!res.ok) throw new Error(`s3_list_failed: ${res.status}`); const text = await res.text(); @@ -89,14 +89,14 @@ export class S3Provider implements BackupProvider { headers: { 'Content-Type': 'application/json' } }); - if (!res.ok) throw new Error(`S3 Upload Failed: ${res.status}`); + if (!res.ok) throw new Error(`s3_upload_failed: ${res.status}`); } async downloadBackup(filename: string): Promise { const key = this.prefix + filename; const url = this.getUrl(key); const res = await this.client.fetch(url); - if (!res.ok) throw new Error(`S3 Download Failed: ${res.status}`); + if (!res.ok) throw new Error(`s3_download_failed: ${res.status}`); return await res.text(); } @@ -106,6 +106,6 @@ export class S3Provider implements BackupProvider { const res = await this.client.fetch(url, { method: 'DELETE' }); - if (!res.ok) throw new Error(`S3 Delete Failed: ${res.status}`); + if (!res.ok) throw new Error(`s3_delete_failed: ${res.status}`); } } \ No newline at end of file diff --git a/backend/src/features/backup/providers/telegramProvider.ts b/backend/src/features/backup/providers/telegramProvider.ts new file mode 100644 index 0000000..0574899 --- /dev/null +++ b/backend/src/features/backup/providers/telegramProvider.ts @@ -0,0 +1,201 @@ +import { BackupProvider, BackupFile } from '@/features/backup/providers/backupProvider'; +import { backupTelegramHistory } from '@/shared/db/schema'; +import { eq, desc, and } from 'drizzle-orm'; + +export class TelegramProvider implements BackupProvider { + private botToken: string; + private chatId: string; + private db: any; + private providerId: number | undefined; + + constructor(config: any, db?: any, providerId?: number) { + if (!config.botToken || !config.chatId) { + throw new Error('telegram_config_incomplete'); + } + + this.botToken = config.botToken; + this.chatId = config.chatId; + this.db = db; + this.providerId = providerId; + } + + private getApiUrl(method: string): string { + return `https://api.telegram.org/bot${this.botToken}/${method}`; + } + + async testConnection(): Promise { + // Send a message and delete it immediately + const sendUrl = this.getApiUrl('sendMessage'); + + try { + const sendRes = await fetch(sendUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + chat_id: this.chatId, + text: '🔗 2FAuth Backup Connected / 备份源连接成功', + disable_notification: true + }) + }); + + if (!sendRes.ok) { + const errResult = (await sendRes.json()) as any; + throw new Error(`telegram_api_error: ${errResult.description}`); + } + + const sendResult = (await sendRes.json()) as any; + const messageId = sendResult.result.message_id; + + // Try to delete immediately + const deleteUrl = this.getApiUrl('deleteMessage'); + await fetch(deleteUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + chat_id: this.chatId, + message_id: messageId + }) + }); + + return true; + } catch (e: any) { + throw e; + } + } + + async listBackups(): Promise { + if (!this.db || !this.providerId) { + throw new Error('telegram_db_missing'); + } + + const histories = await this.db.select() + .from(backupTelegramHistory) + .where(eq(backupTelegramHistory.providerId, this.providerId)) + .orderBy(desc(backupTelegramHistory.createdAt)) + .all(); + + return histories.map((h: any) => ({ + filename: h.filename, + size: h.size, + lastModified: new Date(h.createdAt).toISOString() + })); + } + + async uploadBackup(filename: string, data: string): Promise { + if (!this.db || !this.providerId) { + throw new Error('telegram_db_missing'); + } + + // Use sendDocument + const sendUrl = this.getApiUrl('sendDocument'); + const formData = new FormData(); + formData.append('chat_id', this.chatId); + + // Convert string data to Blob + const blob = new Blob([data], { type: 'application/json' }); + formData.append('document', blob, filename); + + const res = await fetch(sendUrl, { + method: 'POST', + body: formData + }); + + if (!res.ok) { + const errResult = (await res.json()) as any; + throw new Error(`telegram_upload_failed: ${errResult.description}`); + } + + const result = (await res.json()) as any; + const document = result.result.document; + const messageId = result.result.message_id; + const fileId = document.file_id; + const fileSize = document.file_size || data.length; + + // Store in DB + await this.db.insert(backupTelegramHistory).values({ + providerId: this.providerId, + filename: filename, + fileId: fileId, + messageId: messageId, + size: fileSize, + createdAt: Date.now() + }).run(); + } + + async downloadBackup(filename: string): Promise { + if (!this.db || !this.providerId) { + throw new Error('telegram_db_missing'); + } + + // Find fileId + const fileRecord = await this.db.select() + .from(backupTelegramHistory) + .where(and(eq(backupTelegramHistory.providerId, this.providerId), eq(backupTelegramHistory.filename, filename))) + .get(); + + if (!fileRecord) { + throw new Error('file_not_found_in_history'); + } + + // Get file path + const getFileUrl = this.getApiUrl('getFile') + `?file_id=${fileRecord.fileId}`; + const fileInfoRes = await fetch(getFileUrl); + if (!fileInfoRes.ok) { + const errResult = (await fileInfoRes.json()) as any; + if (fileInfoRes.status === 400 && errResult.description && errResult.description.includes('file is unavailable')) { + throw new Error('FILE_UNAVAILABLE'); + } + throw new Error(`telegram_download_failed: ${errResult.description}`); + } + const fileInfo = (await fileInfoRes.json()) as any; + const filePath = fileInfo.result.file_path; + + if (!filePath) { + throw new Error('telegram_file_path_missing'); + } + + // Download actual file content + const downloadUrl = `https://api.telegram.org/file/bot${this.botToken}/${filePath}`; + const downloadRes = await fetch(downloadUrl); + if (!downloadRes.ok) throw new Error(`telegram_download_failed: ${downloadRes.statusText}`); + + return await downloadRes.text(); + } + + async deleteBackup(filename: string): Promise { + if (!this.db || !this.providerId) { + throw new Error('telegram_db_missing'); + } + + const fileRecord = await this.db.select() + .from(backupTelegramHistory) + .where(and(eq(backupTelegramHistory.providerId, this.providerId), eq(backupTelegramHistory.filename, filename))) + .get(); + + if (!fileRecord) { + // Already gone + return; + } + + try { + // Delete message from telegram + const deleteUrl = this.getApiUrl('deleteMessage'); + await fetch(deleteUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + chat_id: this.chatId, + message_id: fileRecord.messageId + }) + }); + } catch (e) { + console.error(`Error deleting message from Telegram:`, e); + // We ignore Telegram errors here because it might be already deleted by the user + } + + // Finally, remove from DB + await this.db.delete(backupTelegramHistory) + .where(eq(backupTelegramHistory.id, fileRecord.id)) + .run(); + } +} diff --git a/backend/src/features/backup/providers/webDavProvider.ts b/backend/src/features/backup/providers/webDavProvider.ts index 946e5fd..3ff2363 100644 --- a/backend/src/features/backup/providers/webDavProvider.ts +++ b/backend/src/features/backup/providers/webDavProvider.ts @@ -8,7 +8,7 @@ export class WebDavProvider implements BackupProvider { constructor(config: any) { if (!config.url || !config.username || !config.password) { - throw new Error('WebDAV configuration incomplete'); + throw new Error('webdav_config_incomplete'); } this.config = config; this.client = createClient(config.url, { @@ -101,7 +101,7 @@ export class WebDavProvider implements BackupProvider { if (!res.ok) { const errText = await res.text().catch(() => ''); - throw new Error(`WebDAV Fallback Upload Failed: ${res.status} ${res.statusText} ${errText}`); + throw new Error(`webdav_upload_failed: ${res.status} ${res.statusText} ${errText}`); } } } @@ -129,11 +129,11 @@ export class WebDavProvider implements BackupProvider { if (redirectUrl) { // 由于跨域重定向规范限制,直接抓取 location 发出裸请求(通常是带有签名的 CDN 下载直链,已无需附带 Authorization) const finalRes = await fetch(redirectUrl); - if (!finalRes.ok) throw new Error(`Redirect fetch failed: ${finalRes.status}`); + if (!finalRes.ok) throw new Error(`webdav_download_redirect_failed: ${finalRes.status}`); return await finalRes.text(); } } else if (!res.ok) { - throw new Error(`WebDAV Fallback Download Failed: ${res.status} ${res.statusText}`); + throw new Error(`webdav_download_failed: ${res.status} ${res.statusText}`); } return await res.text(); @@ -153,7 +153,7 @@ export class WebDavProvider implements BackupProvider { method: 'DELETE', headers: { 'Authorization': this.getAuthHeader() } }); - if (!res.ok) throw new Error(`WebDAV Fallback Delete Failed: ${res.status} ${res.statusText}`); + if (!res.ok) throw new Error(`webdav_delete_failed: ${res.status} ${res.statusText}`); } } } \ No newline at end of file diff --git a/backend/src/shared/db/schema.ts b/backend/src/shared/db/schema.ts index 900c0e5..6ac3e31 100644 --- a/backend/src/shared/db/schema.ts +++ b/backend/src/shared/db/schema.ts @@ -34,8 +34,21 @@ export const backupProviders = sqliteTable('backup_providers', { updatedAt: integer('updated_at').notNull(), }); +// 3. Telegram 备份历史记录表 +export const backupTelegramHistory = sqliteTable('backup_telegram_history', { + id: integer('id').primaryKey({ autoIncrement: true }), + providerId: integer('provider_id').notNull(), + filename: text('filename').notNull(), + fileId: text('file_id').notNull(), + messageId: integer('message_id').notNull(), + size: integer('size').notNull(), + createdAt: integer('created_at').notNull(), +}); + // 导出类型定义 export type VaultItem = typeof vault.$inferSelect; export type NewVaultItem = typeof vault.$inferInsert; export type BackupProvider = typeof backupProviders.$inferSelect; -export type NewBackupProvider = typeof backupProviders.$inferInsert; \ No newline at end of file +export type NewBackupProvider = typeof backupProviders.$inferInsert; +export type BackupTelegramHistory = typeof backupTelegramHistory.$inferSelect; +export type NewBackupTelegramHistory = typeof backupTelegramHistory.$inferInsert; \ No newline at end of file diff --git a/frontend/src/app/styles/main.css b/frontend/src/app/styles/main.css index 83bc37a..48d6a54 100644 --- a/frontend/src/app/styles/main.css +++ b/frontend/src/app/styles/main.css @@ -34,6 +34,7 @@ body, height: 100%; margin: 0; padding: 0; + overflow: hidden; background-color: var(--el-bg-color-page); color: var(--el-text-color-primary); font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; @@ -84,7 +85,7 @@ a:hover { .app-container { display: flex; flex-direction: column; - height: 100vh; + height: 100%; overflow: hidden; } @@ -106,7 +107,8 @@ main { height: 60px; flex-shrink: 0; z-index: 100; - position: relative; + position: sticky; + top: 0; border-bottom: 1px solid var(--el-border-color-light); } diff --git a/frontend/src/features/backup/components/backupSettings.vue b/frontend/src/features/backup/components/backupSettings.vue index 2e4fc13..da0b22b 100644 --- a/frontend/src/features/backup/components/backupSettings.vue +++ b/frontend/src/features/backup/components/backupSettings.vue @@ -68,6 +68,7 @@ + @@ -90,7 +91,7 @@ - + @@ -116,10 +117,28 @@ - + + + {{ $t('backup.auto_backup_config') }} @@ -211,7 +230,7 @@ const layoutStore = useLayoutStore() const { providers, isLoading, showConfigDialog, isEditing, isTesting, isSaving, - isEditingWebdavPwd, isEditingS3Secret, form, + isEditingWebdavPwd, isEditingS3Secret, isEditingTelegramToken, form, hasExistingAutoPwd, configUseExistingAutoPwd, fetchProviders, openAddDialog, editProvider, testConnection, saveProvider, deleteProvider } = useBackupProviders() @@ -223,7 +242,7 @@ const { selectRestoreFile, handleRestore } = useBackupActions(emit, fetchProviders) -const getProviderTypeTag = (type) => type === 'webdav' ? 'primary' : (type === 's3' ? 'warning' : 'info') +const getProviderTypeTag = (type) => type === 'webdav' ? 'primary' : (type === 's3' ? 'warning' : (type === 'telegram' ? 'success' : 'info')) const formatSize = (bytes) => { if (!bytes) return '0 B' diff --git a/frontend/src/features/backup/composables/useBackupActions.js b/frontend/src/features/backup/composables/useBackupActions.js index a80678d..de3110d 100644 --- a/frontend/src/features/backup/composables/useBackupActions.js +++ b/frontend/src/features/backup/composables/useBackupActions.js @@ -1,5 +1,5 @@ import { ref } from 'vue' -import { ElMessage, ElNotification } from 'element-plus' +import { ElMessage, ElNotification, ElMessageBox } from 'element-plus' import { backupService } from '@/features/backup/service/backupService' import { dataMigrationService } from '@/features/migration/service/dataMigrationService' import { useVaultStore } from '@/features/vault/store/vaultStore' @@ -55,7 +55,7 @@ export function useBackupActions(emit, fetchProviders) { if (fetchProviders) await fetchProviders() } } catch (e) { - ElMessage.error(e.message || '备份失败') + // Already handled by request.js } finally { isBackingUp.value = false } } @@ -68,7 +68,7 @@ export function useBackupActions(emit, fetchProviders) { const res = await backupService.getBackupFiles(provider.id) if (res.success) backupFiles.value = res.files } catch (e) { - ElMessage.error(e.message || t('backup.fetch_backup_fail')) + // Error handled by request.js } finally { isLoadingFiles.value = false } } @@ -81,7 +81,7 @@ export function useBackupActions(emit, fetchProviders) { const handleRestore = async () => { isRestoring.value = true try { - const downloadRes = await backupService.downloadBackupFile(currentActionProvider.value.id, selectedFile.value.filename) + const downloadRes = await backupService.downloadBackupFile(currentActionProvider.value.id, selectedFile.value.filename, true) let contentToDecrypt = downloadRes.content try { @@ -119,7 +119,34 @@ export function useBackupActions(emit, fetchProviders) { } } } catch (e) { - ElMessage.error(e.message || t('backup.restore_fail')) + // Check if the backend told us the file was unavailable/deleted on Telegram + if (e.message?.includes('FILE_UNAVAILABLE')) { + // Close the password prompt, but keep the restore list dialog open + showRestoreConfirmDialog.value = false + + ElMessageBox.confirm( + t('backup.file_unavailable'), + t('backup.record_invalid'), + { + confirmButtonText: t('backup.confirm_clean'), + cancelButtonText: t('backup.cancel_clean'), + type: 'warning' + } + ).then(async () => { + try { + await backupService.deleteBackupFile(currentActionProvider.value.id, selectedFile.value.filename) + if (selectedFile.value) { + backupFiles.value = backupFiles.value.filter(f => f.filename !== selectedFile.value.filename) + } + ElMessage.success(t('backup.clean_record_success')) + } catch (err) { + ElMessage.error(err.message || t('backup.clean_record_fail')) + } + }).catch(() => { }) + } else if (!e.message?.includes('connection_failed')) { + // Keep local toast for decryption errors that don't originate from fetch + ElMessage.error(e.message || t('backup.restore_fail')) + } } finally { isRestoring.value = false } } diff --git a/frontend/src/features/backup/composables/useBackupProviders.js b/frontend/src/features/backup/composables/useBackupProviders.js index 910468c..5a59cc2 100644 --- a/frontend/src/features/backup/composables/useBackupProviders.js +++ b/frontend/src/features/backup/composables/useBackupProviders.js @@ -18,11 +18,12 @@ export function useBackupProviders() { const isSaving = ref(false) const isEditingWebdavPwd = ref(false) const isEditingS3Secret = ref(false) + const isEditingTelegramToken = ref(false) const initialFormState = () => ({ type: 'webdav', name: '', - config: { url: '', username: '', password: '', saveDir: '/', endpoint: '', bucket: '', region: 'auto', accessKeyId: '', secretAccessKey: '' }, + config: { url: '', username: '', password: '', saveDir: '/2fauth-worker-backup', endpoint: '', bucket: '', region: 'auto', accessKeyId: '', secretAccessKey: '', botToken: '', chatId: '' }, autoBackup: false, autoBackupPassword: '', autoBackupRetain: 30 @@ -55,6 +56,7 @@ export function useBackupProviders() { isEditing.value = false isEditingWebdavPwd.value = false isEditingS3Secret.value = false + isEditingTelegramToken.value = false form.value = initialFormState() hasExistingAutoPwd.value = false configUseExistingAutoPwd.value = false @@ -65,6 +67,7 @@ export function useBackupProviders() { isEditing.value = true isEditingWebdavPwd.value = false isEditingS3Secret.value = false + isEditingTelegramToken.value = false currentProviderId.value = provider.id form.value = JSON.parse(JSON.stringify({ type: provider.type, @@ -91,6 +94,9 @@ export function useBackupProviders() { if (!c.bucket) return t('backup.require_bucket') if (!c.accessKeyId) return t('backup.require_access_key') if (!c.secretAccessKey) return t('backup.require_secret_key') + } else if (form.value.type === 'telegram') { + if (!c.botToken) return t('backup.require_telegram_token') + if (!c.chatId) return t('backup.require_telegram_chat_id') } if (form.value.autoBackup) { @@ -117,7 +123,7 @@ export function useBackupProviders() { ) if (res.success) ElMessage.success('连接成功') } catch (e) { - ElMessage.error(e.message || '连接失败') + // Already handled by request.js global error handler } finally { isTesting.value = false } } @@ -140,7 +146,7 @@ export function useBackupProviders() { await fetchProviders() } } catch (e) { - ElMessage.error(e.message || '保存失败') + // Already handled by request.js } finally { isSaving.value = false } } @@ -150,9 +156,7 @@ export function useBackupProviders() { await backupService.deleteProvider(provider.id) await fetchProviders() } catch (e) { - if (e !== 'cancel') { - ElMessage.error(e.message || '删除失败') - } + // Error handled by request.js (unless it's 'cancel' from ElMessageBox) } } @@ -167,6 +171,7 @@ export function useBackupProviders() { isSaving, isEditingWebdavPwd, isEditingS3Secret, + isEditingTelegramToken, form, hasExistingAutoPwd, configUseExistingAutoPwd, diff --git a/frontend/src/features/backup/service/backupService.js b/frontend/src/features/backup/service/backupService.js index 113ded9..5c72392 100644 --- a/frontend/src/features/backup/service/backupService.js +++ b/frontend/src/features/backup/service/backupService.js @@ -143,17 +143,37 @@ export const backupService = { * 下载加密的备份文件 * @param {string} id * @param {string} filename + * @param {boolean} silent * @returns {Promise<{success: boolean, content: string|Object}>} * @throws {backupError} */ - async downloadBackupFile(id, filename) { + async downloadBackupFile(id, filename, silent = false) { try { return await request(`/api/backups/providers/${id}/download`, { method: 'POST', - body: JSON.stringify({ filename }) + body: JSON.stringify({ filename }), + silent }) } catch (e) { throw new backupError('Failed to download backup file', 'FILE_DOWNLOAD_FAILED', e) } + }, + + /** + * 删除云端备份文件(如遇到幽灵记录) + * @param {string} id + * @param {string} filename + * @returns {Promise<{success: boolean}>} + * @throws {backupError} + */ + async deleteBackupFile(id, filename) { + try { + return await request(`/api/backups/providers/${id}/files/delete`, { + method: 'POST', + body: JSON.stringify({ filename }) + }) + } catch (e) { + throw new backupError('Failed to delete backup file', 'FILE_DELETE_FAILED', e) + } } } diff --git a/frontend/src/features/vault/components/vaultList.vue b/frontend/src/features/vault/components/vaultList.vue index 632ddb2..bf0ea1e 100644 --- a/frontend/src/features/vault/components/vaultList.vue +++ b/frontend/src/features/vault/components/vaultList.vue @@ -1,8 +1,8 @@