feat: 远端备份增加 Telegram 备份源

This commit is contained in:
nap0o committed 2026-03-07 06:43:37 +08:00
1 parent bfc3f089a8
commit e6a86b2050
20 files changed
+491 -67

No files matched your search

+1 -1
View File
@@ -46,7 +46,7 @@
- 🛡️ **TOTP 密钥分析与生成工具**
### 🔄 企业级数据灾备
- **WebDAV / S3 自动备份**:连接你的Nextcloud、阿里云 OSS 或 AWS S3。
- **WebDAV / S3 / Telegram 自动备份**:连接你的Nextcloud、阿里云 OSS 或 AWS S3,或者 Telegram Bot。
- 自动化定时任务(Cron Triggers)实现无感数据双重灾备。
---
+13 -1
View File
@@ -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
@@ -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();
+41 -7
View File
@@ -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<BackupProvider> {
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);
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);
@@ -1,3 +1,4 @@
export * from '@/features/backup/providers/backupProvider';
export * from '@/features/backup/providers/webDavProvider';
export * from '@/features/backup/providers/s3Provider';
export * from '@/features/backup/providers/s3Provider';
export * from '@/features/backup/providers/telegramProvider';
@@ -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<BackupFile[]> {
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<string> {
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}`);
}
}
@@ -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<boolean> {
// 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<BackupFile[]> {
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<void> {
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<string> {
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<void> {
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();
}
}
@@ -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}`);
}
}
}
+14 -1
View File
@@ -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;
export type NewBackupProvider = typeof backupProviders.$inferInsert;
export type BackupTelegramHistory = typeof backupTelegramHistory.$inferSelect;
export type NewBackupTelegramHistory = typeof backupTelegramHistory.$inferInsert;
+4 -2
View File
@@ -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);
}
@@ -68,6 +68,7 @@
<el-select v-model="form.type" :disabled="isEditing">
<el-option label="WebDAV" value="webdav" />
<el-option :label="$t('backup.type_s3')" value="s3" />
<el-option label="Telegram" value="telegram" />
</el-select>
</el-form-item>
<el-form-item :label="$t('backup.name_label')">
@@ -90,7 +91,7 @@
<el-input v-else v-model="form.config.password" type="password" show-password />
</el-form-item>
<el-form-item :label="$t('backup.save_dir')">
<el-input v-model="form.config.saveDir" placeholder="/2fauth-backups" />
<el-input v-model="form.config.saveDir" placeholder="/2fauth-worker-backup" />
</el-form-item>
</template>
@@ -116,10 +117,28 @@
<el-input v-else v-model="form.config.secretAccessKey" type="password" show-password />
</el-form-item>
<el-form-item :label="$t('backup.s3_path_prefix')">
<el-input v-model="form.config.saveDir" placeholder="backups/" />
<el-input v-model="form.config.saveDir" placeholder="/2fauth-worker-backup" />
</el-form-item>
</template>
<!-- Telegram 配置 -->
<template v-if="form.type === 'telegram'">
<el-form-item :label="$t('backup.telegram_bot_token')">
<div v-if="isEditing && !isEditingTelegramToken" style="display: flex; align-items: center; justify-content: space-between; background-color: var(--el-fill-color-light); padding: 0 15px; border-radius: 4px; border: 1px solid var(--el-border-color); width: 100%; height: 32px;">
<span style="font-family: monospace; letter-spacing: 2px;">******</span>
<el-button link type="primary" @click="isEditingTelegramToken = true; form.config.botToken = ''">{{ $t('backup.modify') }}</el-button>
</div>
<el-input v-else v-model="form.config.botToken" type="password" show-password :placeholder="$t('backup.telegram_bot_token_placeholder')" />
</el-form-item>
<el-form-item :label="$t('backup.telegram_chat_id')">
<el-input v-model="form.config.chatId" :placeholder="$t('backup.telegram_chat_id_placeholder')" />
<div class="form-tip">
<strong>{{ $t('backup.telegram_chat_id_tip_title') }}</strong><br/>
<span>{{ $t('backup.telegram_chat_id_tip_1') }}</span><br/>
<span>{{ $t('backup.telegram_chat_id_tip_2') }}</span>
</div>
</el-form-item>
</template>
<el-divider content-position="left">{{ $t('backup.auto_backup_config') }}</el-divider>
<el-form-item :label="$t('backup.auto_backup')">
<el-switch v-model="form.autoBackup" :active-text="$t('backup.switch_on')" :inactive-text="$t('backup.switch_off')" />
@@ -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'
@@ -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 }
}
@@ -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,
@@ -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)
}
}
}
@@ -1,8 +1,8 @@
<template>
<div class="vault-list-wrapper" style="min-height: 400px;">
<div class="vault-content">
<!-- 移动端偏移 45px (Header2), PC端偏移 61px (Header1) -->
<el-affix :offset="layoutStore.isMobile ? 45 : 61" @change="(val) => isToolbarFixed = val">
<!-- 移动端偏移 0px (Header2), PC端偏移 61px (Header1) -->
<el-affix :offset="layoutStore.isMobile ? 0 : 61" @change="(val) => isToolbarFixed = val">
<div class="toolbar" :class="{ 'is-affixed': isToolbarFixed }" style="margin-bottom: 20px; display: flex; gap: 15px; align-items: center; justify-content: space-between; flex-wrap: wrap;">
<div style="display: flex; align-items: center; gap: 10px; flex: 1;">
<el-input
@@ -272,10 +272,8 @@ onMounted(handleUnlocked)
left: 0 !important;
right: 0 !important;
width: 100vw !important;
background-color: var(--glass-card-bg, rgba(255, 255, 255, 0.95));
backdrop-filter: blur(10px);
border-bottom: 1px solid var(--el-border-color-light);
padding: 10px 20px !important;
background-color: var(--el-bg-color-page);
padding: 14px 28px 14px 20px !important;
border-radius: 0 !important;
}
}
@@ -283,18 +281,10 @@ onMounted(handleUnlocked)
/* PC 端吸顶效果:保持原有宽度,无背景色和下边框 */
@media (min-width: 768px) {
.toolbar.is-affixed {
background-color: var(--el-bg-color-page) !important;
background-color: var(--el-bg-color) !important;
border-bottom: none !important;
padding: 10px 0px 20px 0px !important;
}
}
/* 适配暗黑模式 (仅移动端需要背景) */
@media (max-width: 767px) {
html.dark .toolbar.is-affixed {
background-color: rgba(30, 30, 30, 0.85);
border-bottom: 1px solid var(--el-border-color-dark);
}
}
</style>
@@ -28,12 +28,13 @@ export function useVaultList(afterLoadRef = null) {
const pageSize = ref(12)
// --- Vue Query 分页逻辑 ---
const fetchVaultPage = async ({ pageParam = 1 }) => {
const fetchVaultPage = async ({ pageParam = 1, queryKey }) => {
if (!vaultStore.isUnlocked) return { vault: [], pagination: { totalPages: 0 } }
return await vaultService.getVault({
page: pageParam,
limit: pageSize.value,
search: searchQuery.value
// queryKey[1] is the search string for THIS query's cache key, provided by Vue Query
search: (queryKey && queryKey.length > 1) ? queryKey[1] : searchQuery.value
})
}
@@ -70,11 +71,11 @@ export function useVaultList(afterLoadRef = null) {
const existing = vault.value.find(a => a.id === newAcc.id)
if (existing) {
// 检测关键参数变化:digits, secret, algorithm
const hasParameterChange =
const hasParameterChange =
existing.digits !== newAcc.digits ||
existing.secret !== newAcc.secret ||
existing.algorithm !== newAcc.algorithm
// 若参数变化,强制清空验证码以触发重新计算
if (hasParameterChange) {
return {
@@ -84,7 +85,7 @@ export function useVaultList(afterLoadRef = null) {
forceCompute: true // 标记为需要优先计算
}
}
return { ...existing, ...newAcc } // 保留 currentCode / remaining 等动态字段
}
return {
+34
View File
@@ -138,6 +138,21 @@
"s3_bucket": "Bucket Name",
"s3_region": "Region",
"s3_path_prefix": "Path Prefix (Optional)",
"telegram_bot_token": "Bot Token",
"telegram_bot_token_placeholder": "123456789:ABCdefGHIjklmNOPQrsTUVwxyZ",
"telegram_chat_id": "Chat ID",
"telegram_chat_id_placeholder": "e.g., 123456789 or -100123456789",
"telegram_chat_id_tip_title": "How to get Chat ID",
"telegram_chat_id_tip_1": "1. Private chat back up: Search for userinfobot and send /start to get your ID.",
"telegram_chat_id_tip_2": "2. Group/Channel back up: Add your Bot to the destination group and grant message sending & deleting permissions, then send any message to get the group ID (usually starts with -100).",
"require_telegram_token": "Please enter Bot Token",
"require_telegram_chat_id": "Please enter Chat ID",
"file_unavailable": "This backup file is unavailable or deleted on Telegram. Do you want to clean up this invalid record?",
"record_invalid": "Record Invalid",
"confirm_clean": "Clean Up",
"cancel_clean": "Not Now",
"clean_record_success": "Invalid record cleaned up",
"clean_record_fail": "Failed to clean record",
"auto_backup_config": "Auto Backup Configuration",
"switch_on": "ON",
"switch_off": "OFF",
@@ -435,6 +450,7 @@
"connection_failed": "Connection failed",
"backup_failed": "Backup failed",
"download_failed": "Download failed",
"delete_failed": "Delete failed",
"oauth_config_incomplete": "OAuth configuration incomplete",
"oauth_api_error": "OAuth API error",
"oauth_token_exchange_failed": "OAuth token exchange failed",
@@ -444,6 +460,24 @@
"telegram_missing_hash": "Missing hash signature",
"telegram_signature_failed": "Telegram signature verification failed",
"telegram_login_expired": "Telegram login data has expired",
"telegram_config_incomplete": "Telegram config incomplete (missing Token or Chat ID)",
"telegram_db_missing": "Database connection or Provider ID is missing",
"telegram_api_error": "Telegram API Error",
"telegram_upload_failed": "Telegram upload failed",
"telegram_download_failed": "Telegram download failed",
"file_not_found_in_history": "File not found in history",
"telegram_file_path_missing": "File path not returned from Telegram",
"s3_config_incomplete": "S3 configuration incomplete",
"s3_api_error": "S3 API Error",
"s3_list_failed": "Failed to list S3 objects",
"s3_upload_failed": "S3 upload failed",
"s3_download_failed": "S3 download failed",
"s3_delete_failed": "S3 delete failed",
"webdav_config_incomplete": "WebDAV configuration incomplete",
"webdav_upload_failed": "WebDAV fallback upload failed",
"webdav_download_redirect_failed": "WebDAV redirect fetch failed",
"webdav_download_failed": "WebDAV fallback download failed",
"webdav_delete_failed": "WebDAV fallback delete failed",
"unsupported_provider": "Unsupported provider",
"health_check_failed": "Security vulnerability detected! System access is locked."
},
+34
View File
@@ -138,6 +138,21 @@
"s3_bucket": "Bucket (存储桶名称)",
"s3_region": "Region (区域)",
"s3_path_prefix": "存储路径前缀 (可选)",
"telegram_bot_token": "Bot Token",
"telegram_bot_token_placeholder": "123456789:ABCdefGHIjklmNOPQrsTUVwxyZ",
"telegram_chat_id": "Chat ID",
"telegram_chat_id_placeholder": "例如:123456789 或 -100123456789",
"telegram_chat_id_tip_title": "如何获取 Chat ID",
"telegram_chat_id_tip_1": "1. 私聊备份:搜索 userinfobot 发送 /start 获取您的专属 ID",
"telegram_chat_id_tip_2": "2. 群组/频道备份:将 Bot 拉入目标群组并赋予发言与删除消息权限,发送任意消息后获取群组 ID(通常以 -100 开头)",
"require_telegram_token": "请填写 Bot Token",
"require_telegram_chat_id": "请填写 Chat ID",
"file_unavailable": "该备份文件在 Telegram 中已失效或被删除,是否清理此无效记录?",
"record_invalid": "记录失效",
"confirm_clean": "确定清理",
"cancel_clean": "暂不清理",
"clean_record_success": "已清理失效记录",
"clean_record_fail": "清理记录失败",
"auto_backup_config": "自动备份配置",
"switch_on": "开启",
"switch_off": "关闭",
@@ -435,6 +450,7 @@
"connection_failed": "连接失败",
"backup_failed": "备份失败",
"download_failed": "下载失败",
"delete_failed": "删除失败",
"oauth_config_incomplete": "OAuth 配置不完整",
"oauth_api_error": "OAuth API 错误",
"oauth_token_exchange_failed": "OAuth token 交换失败",
@@ -444,6 +460,24 @@
"telegram_missing_hash": "缺少哈希签名",
"telegram_signature_failed": "Telegram 签名验证失败",
"telegram_login_expired": "Telegram 登录数据已过期",
"telegram_config_incomplete": "Telegram 配置不完整 (缺少 Token 或 Chat ID)",
"telegram_db_missing": "缺少数据库连接或 Provider ID",
"telegram_api_error": "Telegram API 错误",
"telegram_upload_failed": "Telegram 上传失败",
"telegram_download_failed": "Telegram 下载失败",
"file_not_found_in_history": "在历史记录中找不到此文件",
"telegram_file_path_missing": "Telegram 未返回文件路径",
"s3_config_incomplete": "S3 配置不完整",
"s3_api_error": "S3 API 错误",
"s3_list_failed": "获取 S3 对象列表失败",
"s3_upload_failed": "S3 上传失败",
"s3_download_failed": "S3 下载失败",
"s3_delete_failed": "S3 删除失败",
"webdav_config_incomplete": "WebDAV 配置不完整",
"webdav_upload_failed": "WebDAV 备选方案上传失败",
"webdav_download_redirect_failed": "WebDAV 重定向请求失败",
"webdav_download_failed": "WebDAV 备选方案下载失败",
"webdav_delete_failed": "WebDAV 备选方案删除失败",
"unsupported_provider": "不支持的提供商",
"health_check_failed": "环境配置漏洞警告,系统已被锁定!"
},
+11 -6
View File
@@ -24,21 +24,22 @@ const themeStore = useThemeStore()
themeStore.initTheme()
// PWA Service Worker 注册
registerSW({
onOfflineReady() {
const updateSW = registerSW({
onofflineReady() { },
onNeedRefresh() {
import('element-plus').then(({ ElNotification }) => {
ElNotification({
title: i18n.global.t('common.update_available'),
title: i18n.global.t('common.update_available') || 'Update Available',
message: `
<div style="line-height: 1.5; font-size: 14px; margin-bottom: 5px;">
🎉 ${i18n.global.t('common.pwa_update_ready')}
🎉 ${i18n.global.t('common.pwa_update_ready') || 'A new version is ready!'}
</div>
<div>
<button
style="background: var(--el-color-primary); color: white; border: none; padding: 5px 12px; border-radius: 4px; cursor: pointer; font-size: 13px;"
onclick="window.location.reload()"
id="pwa-refresh-btn"
>
${i18n.global.t('common.refresh_now')}
${i18n.global.t('common.refresh_now') || 'Refresh Now'}
</button>
</div>
`,
@@ -46,6 +47,10 @@ registerSW({
type: 'success',
duration: 0
})
setTimeout(() => {
const btn = document.getElementById('pwa-refresh-btn')
if (btn) btn.onclick = () => updateSW(true)
}, 100)
})
},
})
+18
View File
@@ -85,6 +85,12 @@ export async function request(url, options = {}) {
: fallbackMessage
if (errorDetails) {
let detailParts = errorDetails.split(':')
let detailKey = detailParts[0].trim()
if (i18n.global.te(`api_errors.${detailKey}`)) {
detailParts[0] = i18n.global.t(`api_errors.${detailKey}`)
errorDetails = detailParts.join(': ')
}
translatedMessage = `${translatedMessage}: ${errorDetails}`
}
@@ -134,6 +140,12 @@ export async function request(url, options = {}) {
: fallbackError
if (errorDetails) {
let detailParts = errorDetails.split(':')
let detailKey = detailParts[0].trim()
if (i18n.global.te(`api_errors.${detailKey}`)) {
detailParts[0] = i18n.global.t(`api_errors.${detailKey}`)
errorDetails = detailParts.join(': ')
}
translatedError = `${translatedError}: ${errorDetails}`
}
ElMessage.error(translatedError)
@@ -152,6 +164,12 @@ export async function request(url, options = {}) {
// 屏蔽被 silent 处理过的 Auth Error
if (error.message !== 'Unauthorized/Forbidden' && !options.silent) {
console.error('API Request Error:', error)
// Only show toast if it wasn't already shown in the !response.ok block above
// The throwing logic above throws an Error with a specific format, we only toast here if it's a fetch/network level error
if (error.name === 'TypeError' || error.message.includes('fetch')) {
ElMessage.error(i18n.global.te('api_errors.network_error') ? i18n.global.t('api_errors.network_error') : '网络请求失败')
}
}
throw error
}