From 52c9b0a454cb89cf71752aaf91e46447b39cae91 Mon Sep 17 00:00:00 2001 From: nap0o Date: Thu, 12 Mar 2026 19:35:01 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BA=91=E7=AB=AF=E5=A4=87=E4=BB=BD?= =?UTF-8?q?=E5=A2=9E=E5=8A=A0=20Dropbox=20=E5=A4=87=E4=BB=BD=E6=BA=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/deploy.yml | 5 + backend/src/app/config.ts | 6 + backend/src/features/backup/backupRoutes.ts | 180 ++++++++++++++++++ backend/src/features/backup/backupService.ts | 13 +- .../backup/providers/dropboxProvider.ts | 169 ++++++++++++++++ .../src/features/backup/providers/index.ts | 3 +- .../app/styles/modules/components/backup.css | 22 +++ .../styles/modules/components/migration.css | 143 ++++++++++---- .../backup/components/backupSettings.vue | 70 ++++++- .../backup/composables/useBackupProviders.js | 76 +++++++- .../features/backup/service/backupService.js | 12 ++ .../features/migration/views/dataImport.vue | 53 ++++-- frontend/src/locales/en-US.json | 9 +- frontend/src/locales/zh-CN.json | 9 +- .../shared/components/icons/iconDropbox.vue | 8 + 15 files changed, 701 insertions(+), 77 deletions(-) create mode 100644 backend/src/features/backup/providers/dropboxProvider.ts create mode 100644 frontend/src/shared/components/icons/iconDropbox.vue diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index eac34ad..3816e52 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -84,6 +84,8 @@ jobs: OAUTH_MICROSOFT_CLIENT_SECRET: ${{ secrets.OAUTH_MICROSOFT_CLIENT_SECRET }} OAUTH_BAIDU_CLIENT_ID: ${{ secrets.OAUTH_BAIDU_CLIENT_ID }} OAUTH_BAIDU_CLIENT_SECRET: ${{ secrets.OAUTH_BAIDU_CLIENT_SECRET }} + OAUTH_DROPBOX_CLIENT_ID: ${{ secrets.OAUTH_DROPBOX_CLIENT_ID }} + OAUTH_DROPBOX_CLIENT_SECRET: ${{ secrets.OAUTH_DROPBOX_CLIENT_SECRET }} run: | SECRETS=" @@ -101,6 +103,7 @@ jobs: "OAUTH_GOOGLE_CLIENT_ID" "OAUTH_GOOGLE_CLIENT_SECRET" "OAUTH_GOOGLE_REDIRECT_URI" "OAUTH_MICROSOFT_CLIENT_ID" "OAUTH_MICROSOFT_CLIENT_SECRET" "OAUTH_BAIDU_CLIENT_ID" "OAUTH_BAIDU_CLIENT_SECRET" + "OAUTH_DROPBOX_CLIENT_ID" "OAUTH_DROPBOX_CLIENT_SECRET" ) for VAR in "${OPTIONAL_VARS[@]}"; do @@ -153,6 +156,8 @@ jobs: OAUTH_MICROSOFT_CLIENT_SECRET: ${{ secrets.OAUTH_MICROSOFT_CLIENT_SECRET }} OAUTH_BAIDU_CLIENT_ID: ${{ secrets.OAUTH_BAIDU_CLIENT_ID }} OAUTH_BAIDU_CLIENT_SECRET: ${{ secrets.OAUTH_BAIDU_CLIENT_SECRET }} + OAUTH_DROPBOX_CLIENT_ID: ${{ secrets.OAUTH_DROPBOX_CLIENT_ID }} + OAUTH_DROPBOX_CLIENT_SECRET: ${{ secrets.OAUTH_DROPBOX_CLIENT_SECRET }} ENCRYPTION_KEY: ${{ secrets.ENCRYPTION_KEY }} JWT_SECRET: ${{ secrets.JWT_SECRET }} diff --git a/backend/src/app/config.ts b/backend/src/app/config.ts index 6ce13b6..8e7f2d0 100644 --- a/backend/src/app/config.ts +++ b/backend/src/app/config.ts @@ -47,6 +47,9 @@ export const CSP_POLICY = { "https://graph.microsoft.com", "https://openapi.baidu.com", "https://pan.baidu.com", + "https://api.dropboxapi.com", + "https://content.dropboxapi.com", + "https://www.dropbox.com", ], // 框架源 FRAMES: [ @@ -82,6 +85,9 @@ export type EnvBindings = { OAUTH_BAIDU_CLIENT_ID?: string; OAUTH_BAIDU_CLIENT_SECRET?: string; OAUTH_BAIDU_BACKUP_REDIRECT_URI?: string; + OAUTH_DROPBOX_CLIENT_ID?: string; + OAUTH_DROPBOX_CLIENT_SECRET?: string; + OAUTH_DROPBOX_BACKUP_REDIRECT_URI?: string; OAUTH_ALLOWED_USERS: string; // 允许登录的 Email 或 Username 白名单 (必填) OAUTH_ALLOW_ALL?: string; // 是否允许所有用户登录 (仅推荐用于测试演示环境) JWT_SECRET: string; diff --git a/backend/src/features/backup/backupRoutes.ts b/backend/src/features/backup/backupRoutes.ts index 2ed052b..5edfc9d 100644 --- a/backend/src/features/backup/backupRoutes.ts +++ b/backend/src/features/backup/backupRoutes.ts @@ -469,6 +469,152 @@ backups.get('/oauth/baidu/callback', async (c) => { `); }); +// --- Dropbox OAuth Callback --- +backups.get('/oauth/dropbox/callback', async (c) => { + c.header('Cross-Origin-Opener-Policy', 'unsafe-none'); + + const stateInQuery = c.req.query('state'); + const stateInCookie = getCookie(c, 'dropbox_oauth_state'); + + if (!stateInQuery || !stateInCookie || stateInQuery !== stateInCookie) { + return c.html(` + + `); + } + + const token = getCookie(c, 'auth_token'); + if (!token) { + return c.html(` + + `); + } + + const payload = await verifySecureJWT(token, c.env.JWT_SECRET); + if (!payload?.userInfo) { + return c.html(` + + `); + } + + const code = c.req.query('code'); + const error = c.req.query('error'); + if (error === 'access_denied') { + return c.html(` + + `); + } + if (!code) { + return c.html(` + + `); + } + + const clientId = c.env.OAUTH_DROPBOX_CLIENT_ID; + const clientSecret = c.env.OAUTH_DROPBOX_CLIENT_SECRET; + const redirectUri = c.env.OAUTH_DROPBOX_BACKUP_REDIRECT_URI || `${new URL(c.req.url).origin}/api/backups/oauth/dropbox/callback`; + + const tokenRes = await fetch('https://api.dropboxapi.com/oauth2/token', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + client_id: clientId || '', + client_secret: clientSecret || '', + code: code, + grant_type: 'authorization_code', + redirect_uri: redirectUri + }) + }); + + if (!tokenRes.ok) { + const errData = await tokenRes.json() as any; + console.error('[OAuth] Dropbox Token exchange failed:', errData); + return c.html(` + + `); + } + + const tokenData = await tokenRes.json() as any; + const refreshToken = tokenData.refresh_token; + + if (!refreshToken) { + return c.html(` + + `); + } + + return c.html(` + + Success + +
授权成功,正在返回应用...
+ + + + `); +}); + // ========================================================================= // === PROTECTED ROUTES === // All routes below this middleware require a valid JWT AND a valid CSRF token. @@ -490,6 +636,9 @@ backups.get('/providers', async (c) => { if (c.env.OAUTH_BAIDU_CLIENT_ID && c.env.OAUTH_BAIDU_CLIENT_SECRET) { availableTypes.push('baidu'); } + if (c.env.OAUTH_DROPBOX_CLIENT_ID && c.env.OAUTH_DROPBOX_CLIENT_SECRET) { + availableTypes.push('dropbox'); + } return c.json({ success: true, providers, availableTypes }); }); @@ -654,6 +803,37 @@ backups.post('/oauth/baidu/auth', async (c) => { }); }); +// --- Dropbox OAuth Initiation --- +backups.post('/oauth/dropbox/auth', async (c) => { + const clientId = c.env.OAUTH_DROPBOX_CLIENT_ID; + const redirectUri = c.env.OAUTH_DROPBOX_BACKUP_REDIRECT_URI || `${new URL(c.req.url).origin}/api/backups/oauth/dropbox/callback`; + + if (!clientId) throw new AppError('oauth_config_incomplete', 400); + + const state = crypto.randomUUID(); + + setCookie(c, 'dropbox_oauth_state', state, { + path: '/api/backups/oauth/dropbox/callback', + secure: true, + httpOnly: true, + sameSite: 'Lax', + maxAge: 600 + }); + + const params = new URLSearchParams({ + client_id: clientId, + redirect_uri: redirectUri, + response_type: 'code', + token_access_type: 'offline', // Request refresh token + state: state + }); + + return c.json({ + success: true, + authUrl: `https://www.dropbox.com/oauth2/authorize?${params.toString()}` + }); +}); + 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 a27b6dd..af9b232 100644 --- a/backend/src/features/backup/backupService.ts +++ b/backend/src/features/backup/backupService.ts @@ -2,7 +2,7 @@ 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 } from '@/features/backup/providers'; +import { BackupProvider, WebDavProvider, S3Provider, TelegramProvider, GoogleDriveProvider, OneDriveProvider, BaiduNetdiskProvider, DropboxProvider } from '@/features/backup/providers'; import { decryptField } from '@/shared/db/db'; import { vault as vaultTable, backupProviders } from '@/shared/db/schema'; @@ -24,7 +24,8 @@ export class BackupService { telegram: ['botToken'], gdrive: ['refreshToken'], onedrive: ['refreshToken'], - baidu: ['refreshToken'] + baidu: ['refreshToken'], + dropbox: ['refreshToken'] }; private maskConfigForFrontend(type: string, config: any) { @@ -86,6 +87,8 @@ export class BackupService { return new OneDriveProvider(config, this.env); case 'baidu': return new BaiduNetdiskProvider(config, this.env); + case 'dropbox': + return new DropboxProvider(config, this.env); default: throw new AppError('provider_not_found', 400); } @@ -111,6 +114,9 @@ export class BackupService { if (type === 'baidu' && processed.refreshToken) { processed.refreshToken = await encryptData(processed.refreshToken, key); } + if (type === 'dropbox' && processed.refreshToken) { + processed.refreshToken = await encryptData(processed.refreshToken, key); + } return JSON.stringify(processed); } @@ -134,6 +140,9 @@ export class BackupService { if (type === 'baidu' && config.refreshToken) { config.refreshToken = await decryptData(config.refreshToken, key); } + if (type === 'dropbox' && config.refreshToken) { + config.refreshToken = await decryptData(config.refreshToken, key); + } return config; } diff --git a/backend/src/features/backup/providers/dropboxProvider.ts b/backend/src/features/backup/providers/dropboxProvider.ts new file mode 100644 index 0000000..bbcab0c --- /dev/null +++ b/backend/src/features/backup/providers/dropboxProvider.ts @@ -0,0 +1,169 @@ +import { BackupProvider, BackupFile } from '@/features/backup/providers/backupProvider'; +import { AppError } from '@/app/config'; + +export class DropboxProvider implements BackupProvider { + private clientId: string; + private clientSecret: string; + private refreshToken: string; + private accessToken: string | null = null; + private saveDir: string; + + constructor(config: any, env: any) { + this.clientId = env.OAUTH_DROPBOX_CLIENT_ID; + this.clientSecret = env.OAUTH_DROPBOX_CLIENT_SECRET; + + if (!config.refreshToken) { + throw new Error('dropbox_token_missing'); + } + this.refreshToken = config.refreshToken; + this.saveDir = config.saveDir || ''; // Dropbox App Folder is root by default + } + + private async getAccessToken(): Promise { + if (this.accessToken) return this.accessToken; + + const res = await fetch('https://api.dropboxapi.com/oauth2/token', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + client_id: this.clientId, + client_secret: this.clientSecret, + refresh_token: this.refreshToken, + grant_type: 'refresh_token' + }) + }); + + if (!res.ok) { + const err = await res.json() as any; + const errorDesc = (err.error || err.error_description || '').toLowerCase(); + if (errorDesc.includes('invalid_grant') || res.status === 401) { + throw new AppError('oauth_token_revoked', 401); + } + throw new Error(`dropbox_auth_failed: ${err.error_description || res.statusText}`); + } + + const data = await res.json() as any; + this.accessToken = data.access_token; + return this.accessToken!; + } + + async testConnection(): Promise { + await this.getAccessToken(); + // Simple list check + await this.listBackups(); + return true; + } + + async listBackups(): Promise { + const token = await this.getAccessToken(); + const path = this.saveDir === '/' || this.saveDir === '' ? '' : (this.saveDir.startsWith('/') ? this.saveDir : '/' + this.saveDir); + + const res = await fetch('https://api.dropboxapi.com/2/files/list_folder', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + path: path, + recursive: false, + include_media_info: false, + include_deleted: false + }) + }); + + if (res.status === 409) { + // Path not found, return empty + return []; + } + + if (!res.ok) { + const err = await res.json() as any; + if (err?.error?.['.tag'] === 'path' && err?.error?.path?.['.tag'] === 'not_found') { + return []; + } + throw new Error(`dropbox_list_failed: ${res.status}`); + } + + const data = await res.json() as any; + return (data.entries || []) + .filter((f: any) => f['.tag'] === 'file' && f.name.includes('2fa-backup-')) + .map((f: any) => ({ + filename: f.name, + size: f.size, + lastModified: f.server_modified + })); + } + + async uploadBackup(filename: string, data: string): Promise { + const token = await this.getAccessToken(); + const path = this.saveDir === '/' || this.saveDir === '' ? '' : (this.saveDir.startsWith('/') ? this.saveDir : '/' + this.saveDir); + const fullPath = `${path}/${filename}`.replace(/\/+/g, '/'); + + const args = { + path: fullPath, + mode: 'overwrite', + autorename: false, + mute: false, + strict_conflict: false + }; + + const res = await fetch('https://content.dropboxapi.com/2/files/upload', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Dropbox-API-Arg': JSON.stringify(args), + 'Content-Type': 'application/octet-stream' + }, + body: data + }); + + if (!res.ok) { + const err = await res.json() as any; + throw new Error(`dropbox_upload_failed: ${err.error_summary || res.statusText}`); + } + } + + async downloadBackup(filename: string): Promise { + const token = await this.getAccessToken(); + const path = this.saveDir === '/' || this.saveDir === '' ? '' : (this.saveDir.startsWith('/') ? this.saveDir : '/' + this.saveDir); + const fullPath = `${path}/${filename}`.replace(/\/+/g, '/'); + + const args = { + path: fullPath + }; + + const res = await fetch('https://content.dropboxapi.com/2/files/download', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Dropbox-API-Arg': JSON.stringify(args) + } + }); + + if (!res.ok) throw new Error(`dropbox_download_failed: ${res.status}`); + return await res.text(); + } + + async deleteBackup(filename: string): Promise { + const token = await this.getAccessToken(); + const path = this.saveDir === '/' || this.saveDir === '' ? '' : (this.saveDir.startsWith('/') ? this.saveDir : '/' + this.saveDir); + const fullPath = `${path}/${filename}`.replace(/\/+/g, '/'); + + const res = await fetch('https://api.dropboxapi.com/2/files/delete_v2', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ path: fullPath }) + }); + + if (!res.ok) { + const err = await res.json() as any; + // Ignore if already deleted + if (err?.error_summary?.includes('path_lookup/not_found')) return; + throw new Error(`dropbox_delete_failed: ${res.statusText}`); + } + } +} diff --git a/backend/src/features/backup/providers/index.ts b/backend/src/features/backup/providers/index.ts index 12cc011..19bc9a1 100644 --- a/backend/src/features/backup/providers/index.ts +++ b/backend/src/features/backup/providers/index.ts @@ -4,4 +4,5 @@ export * from '@/features/backup/providers/s3Provider'; export * from '@/features/backup/providers/telegramProvider'; export * from '@/features/backup/providers/googleDriveProvider'; export * from '@/features/backup/providers/oneDriveProvider'; -export * from '@/features/backup/providers/baiduNetdiskProvider'; \ No newline at end of file +export * from '@/features/backup/providers/baiduNetdiskProvider'; +export * from '@/features/backup/providers/dropboxProvider'; \ No newline at end of file diff --git a/frontend/src/app/styles/modules/components/backup.css b/frontend/src/app/styles/modules/components/backup.css index 6cca563..f4ba63d 100644 --- a/frontend/src/app/styles/modules/components/backup.css +++ b/frontend/src/app/styles/modules/components/backup.css @@ -241,4 +241,26 @@ .dark .btn-baidu-auth:hover { background-color: #222850; +} + +.btn-dropbox-auth { + background-color: #ffffff; + border: 1px solid #0061FF; + color: #0061FF; +} + +.btn-dropbox-auth:hover { + background-color: #f0f7ff; + border-color: #0061FF; + box-shadow: 0 1px 3px 0 rgba(0, 97, 255, 0.30), 0 4px 8px 3px rgba(0, 97, 255, 0.15); +} + +.dark .btn-dropbox-auth { + background-color: #002d75; + border-color: #0061FF; + color: #ffffff; +} + +.dark .btn-dropbox-auth:hover { + background-color: #00368c; } \ No newline at end of file diff --git a/frontend/src/app/styles/modules/components/migration.css b/frontend/src/app/styles/modules/components/migration.css index 4c91191..e7bd8b7 100644 --- a/frontend/src/app/styles/modules/components/migration.css +++ b/frontend/src/app/styles/modules/components/migration.css @@ -139,42 +139,68 @@ } .ecosystem-groups { - display: flex; - flex-direction: column; - gap: 25px; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); + gap: 20px; } .ecosystem-group { display: flex; flex-direction: column; - gap: 12px; + gap: 16px; + padding: 20px 20px 0; + background-color: none; + border: 1px solid var(--el-border-color-lighter); + border-radius: 12px; + transition: all 0.3s ease; +} + +.ecosystem-group:hover { + border-color: var(--el-border-color); + box-shadow: var(--el-box-shadow-lighter); } .group-title { margin: 0; font-size: 15px; - color: var(--el-text-color-regular); - font-weight: 600; + color: var(--el-text-color-primary); + font-weight: 700; + display: flex; + align-items: center; + gap: 8px; } .ecosystem-grid { display: flex; flex-wrap: wrap; - gap: 12px 24px; + gap: 10px; } .ecosystem-item { display: inline-flex; align-items: center; gap: 8px; - font-size: 14px; - color: var(--el-text-color-primary); + font-size: 13px; + color: var(--el-text-color-regular); line-height: 1.2; + padding: 6px 12px; + background: var(--el-fill-color-lighter); + border-radius: 20px; + border: 1px solid transparent; + transition: all 0.2s ease; +} + +.ecosystem-item:hover { + color: var(--el-color-primary); + background: var(--el-color-primary-light-9); + border-color: var(--el-color-primary-light-8); + transform: translateY(-1px); } .ecosystem-item .el-icon, .ecosystem-item svg { - font-size: 18px; + font-size: 16px; + opacity: 0.85; } /* 疑难解答折叠面板 */ @@ -185,9 +211,10 @@ } .ecosystem-collapse :deep(.el-collapse), +.ecosystem-collapse :deep(.el-collapse-item), .ecosystem-collapse :deep(.el-collapse-item__header), .ecosystem-collapse :deep(.el-collapse-item__wrap) { - border: none; + border: none !important; } .ecosystem-collapse :deep(.el-collapse-item__header) { @@ -204,57 +231,89 @@ } .ecosystem-collapse :deep(.el-collapse-item__content) { - padding: 10px 0; + padding: 0; +} + +.migration-ga-tip, +.migration-ms-tip { + padding: 12px 16px; + border-radius: 8px; + border: 1px solid transparent; + margin-top: 10px; } .migration-ga-tip { background-color: var(--el-color-primary-light-9); - padding: 10px 12px; - border-radius: 6px; - border-left: 3px solid var(--el-color-primary); - margin-top: 5px; -} - -.migration-ga-tip span { - font-weight: 600; - font-size: 13px; - color: var(--el-color-primary); - display: block; - margin-bottom: 4px; -} - -.migration-ga-tip p { - margin: 0; - font-size: 12px; - color: var(--el-text-color-regular); - line-height: 1.5; + border-color: var(--el-color-primary-light-8); } .migration-ms-tip { background-color: var(--el-color-success-light-9); - padding: 10px 12px; - border-radius: 6px; - border-left: 3px solid var(--el-color-success); - margin-top: 15px; + border-color: var(--el-color-success-light-8); +} + +.migration-ga-tip span, +.migration-ms-tip span { + font-weight: 700; + font-size: 13px; + display: flex; + align-items: center; + gap: 6px; + margin-bottom: 8px; +} + +.migration-ga-tip span { + color: var(--el-color-primary); } .migration-ms-tip span { - font-weight: 600; - font-size: 13px; color: var(--el-color-success); - display: block; - margin-bottom: 4px; } +.migration-ga-tip p, .migration-ms-tip p { margin: 0; font-size: 12px; - color: var(--el-color-regular); - line-height: 1.5; + color: var(--el-text-color-regular); + line-height: 1.6; } .migration-ms-tip code { - word-wrap: break-word; + display: block; + margin-top: 6px; + padding: 6px 10px; + background: rgba(0, 0, 0, 0.05); + border-radius: 4px; + font-family: var(--el-font-family-mono); + font-size: 11px; + word-break: break-all; + color: var(--el-text-color-regular); + border: 1px solid rgba(0, 0, 0, 0.05); +} + +/* 深色模式优化 */ +html.dark .migration-ga-tip { + background-color: rgba(64, 158, 255, 0.1); + border-color: rgba(64, 158, 255, 0.2); +} + +html.dark .migration-ms-tip { + background-color: rgba(103, 194, 58, 0.1); + border-color: rgba(103, 194, 58, 0.2); +} + +html.dark .migration-ms-tip code { + background: rgba(255, 255, 255, 0.05); + border-color: rgba(255, 255, 255, 0.1); + color: var(--el-text-color-secondary); +} + +html.dark .ecosystem-item { + background: var(--el-fill-color-light); +} + +html.dark .ecosystem-item:hover { + background: rgba(64, 158, 255, 0.15); } /* dataMigration.vue */ diff --git a/frontend/src/features/backup/components/backupSettings.vue b/frontend/src/features/backup/components/backupSettings.vue index df2b72d..f2cd594 100644 --- a/frontend/src/features/backup/components/backupSettings.vue +++ b/frontend/src/features/backup/components/backupSettings.vue @@ -80,6 +80,7 @@ + @@ -327,6 +328,61 @@ + + {{ $t('backup.auto_backup_config') }} @@ -419,6 +475,7 @@ import { Plus, Edit, Delete, CircleCheck, CircleClose, Timer, Loading } from '@e import iconGoogleDrive from '@/shared/components/icons/iconGoogleDrive.vue' import iconOnedrive from '@/shared/components/icons/iconOnedrive.vue' import iconBaiduNetdisk from '@/shared/components/icons/iconBaiduNetdisk.vue' +import iconDropbox from '@/shared/components/icons/iconDropbox.vue' import { useLayoutStore } from '@/shared/stores/layoutStore' import { useBackupProviders } from '@/features/backup/composables/useBackupProviders' import { useBackupActions } from '@/features/backup/composables/useBackupActions' @@ -431,10 +488,10 @@ const { isEditingWebdavPwd, isEditingS3Secret, isEditingTelegramToken, form, isAutoBackupPasswordSaved, shouldUseExistingAutoBackupPassword, fetchProviders, openAddDialog, editProvider, testConnection, saveProvider, deleteProvider, - startGoogleAuth, startMicrosoftAuth, startBaiduAuth, handleAuthMessage, - isAuthenticatingGoogle, isAuthenticatingMicrosoft, isAuthenticatingBaidu, - authStatusGoogle, authStatusMicrosoft, authStatusBaidu, - authErrorMessageGoogle, authErrorMessageMicrosoft, authErrorMessageBaidu, + startGoogleAuth, startMicrosoftAuth, startBaiduAuth, startDropboxAuth, handleAuthMessage, + isAuthenticatingGoogle, isAuthenticatingMicrosoft, isAuthenticatingBaidu, isAuthenticatingDropbox, + authStatusGoogle, authStatusMicrosoft, authStatusBaidu, authStatusDropbox, + authErrorMessageGoogle, authErrorMessageMicrosoft, authErrorMessageBaidu, authErrorMessageDropbox, setupAuthListener, availableTypes } = useBackupProviders() @@ -465,8 +522,9 @@ const getProviderTypeTag = (type) => { s3: 'warning', telegram: 'success', gdrive: 'danger', - onedrive: '', - baidu: 'primary' + onedrive: 'success', + baidu: 'warning', + dropbox: 'primary' } return map[type] || 'info' } diff --git a/frontend/src/features/backup/composables/useBackupProviders.js b/frontend/src/features/backup/composables/useBackupProviders.js index 53c39c4..8d4ca9d 100644 --- a/frontend/src/features/backup/composables/useBackupProviders.js +++ b/frontend/src/features/backup/composables/useBackupProviders.js @@ -26,12 +26,15 @@ export function useBackupProviders() { const isAuthenticatingGoogle = ref(false) const isAuthenticatingMicrosoft = ref(false) const isAuthenticatingBaidu = ref(false) + const isAuthenticatingDropbox = ref(false) const authStatusGoogle = ref(null) // null, 'success', 'error' const authStatusMicrosoft = ref(null) const authStatusBaidu = ref(null) + const authStatusDropbox = ref(null) const authErrorMessageGoogle = ref('') const authErrorMessageMicrosoft = ref('') const authErrorMessageBaidu = ref('') + const authErrorMessageDropbox = ref('') const initialFormState = () => ({ type: 's3', @@ -119,6 +122,8 @@ export function useBackupProviders() { if (!config.refreshToken) return t('backup.require_microsoft_auth') } else if (form.value.type === 'baidu') { if (!config.refreshToken) return t('backup.require_baidu_auth') + } else if (form.value.type === 'dropbox') { + if (!config.refreshToken) return t('backup.require_dropbox_auth') } if (form.value.autoBackup) { @@ -137,13 +142,15 @@ export function useBackupProviders() { if (error) return ElMessage.warning(error) isTesting.value = true; - // Reset old error states before starting a new test to ensure UI reflects current result - authStatusGoogle.value = null; + // Only reset error states, keep 'success' state if it exists + if (authStatusGoogle.value === 'error') authStatusGoogle.value = null; authErrorMessageGoogle.value = ''; - authStatusMicrosoft.value = null; + if (authStatusMicrosoft.value === 'error') authStatusMicrosoft.value = null; authErrorMessageMicrosoft.value = ''; - authStatusBaidu.value = null; + if (authStatusBaidu.value === 'error') authStatusBaidu.value = null; authErrorMessageBaidu.value = ''; + if (authStatusDropbox.value === 'error') authStatusDropbox.value = null; + authErrorMessageDropbox.value = ''; try { const res = await backupService.testConnection( @@ -151,7 +158,15 @@ export function useBackupProviders() { form.value.config, isEditing.value ? currentProviderId.value : null ) - if (res.success) ElMessage.success(t('backup.test_success')) + if (res.success) { + ElMessage.success(t('backup.test_success')) + // Ensure the success status is reflected in the UI for OAuth providers + const type = form.value.type + if (type === 'gdrive') authStatusGoogle.value = 'success' + else if (type === 'onedrive') authStatusMicrosoft.value = 'success' + else if (type === 'baidu') authStatusBaidu.value = 'success' + else if (type === 'dropbox') authStatusDropbox.value = 'success' + } } catch (e) { // Error is handled here natively since we silenced the global request.js interceptor for testConnection const rawMsg = e?.details?.message || e?.message || e?.response?.data?.message || (typeof e === 'string' ? e : t('common.error')); @@ -175,6 +190,9 @@ export function useBackupProviders() { } else if (type === 'baidu') { authStatusBaidu.value = 'error'; authErrorMessageBaidu.value = t('backup.token_expired_or_revoked'); + } else if (type === 'dropbox') { + authStatusDropbox.value = 'error'; + authErrorMessageDropbox.value = t('backup.token_expired_or_revoked'); } // If we are editing an existing provider, update the global store to reflect the state @@ -313,6 +331,35 @@ export function useBackupProviders() { } } + const startDropboxAuth = async () => { + isAuthenticatingDropbox.value = true + authStatusDropbox.value = null + authErrorMessageDropbox.value = '' + try { + const res = await backupService.getDropboxAuthUrl() + if (res.success && res.authUrl) { + const name = 'dropbox_auth' + const specs = 'width=600,height=700,left=200,top=100' + const authWindow = window.open(res.authUrl, name, specs) + + const timer = setInterval(() => { + try { + if (authWindow && authWindow.closed) { + clearInterval(timer) + if (isAuthenticatingDropbox.value && !authStatusDropbox.value) { + isAuthenticatingDropbox.value = false + } + } + } catch (e) { } + }, 1000) + } else { + isAuthenticatingDropbox.value = false + } + } catch (e) { + isAuthenticatingDropbox.value = false + } + } + const handleAuthMessage = async (event) => { const data = event instanceof MessageEvent ? event.data : event if (!data || !data.type) return @@ -350,6 +397,17 @@ export function useBackupProviders() { isAuthenticatingBaidu.value = false authStatusBaidu.value = 'error' authErrorMessageBaidu.value = data.message || t('backup.baidu_auth_failed') + } else if (data.type === 'DROPBOX_AUTH_SUCCESS') { + isAuthenticatingDropbox.value = false + authStatusDropbox.value = 'success' + form.value.config.refreshToken = data.refreshToken + if (!form.value.config.saveDir) { + form.value.config.saveDir = '' + } + } else if (data.type === 'DROPBOX_AUTH_ERROR') { + isAuthenticatingDropbox.value = false + authStatusDropbox.value = 'error' + authErrorMessageDropbox.value = data.message || t('backup.dropbox_auth_failed') } } @@ -377,11 +435,15 @@ export function useBackupProviders() { const bcBaidu = new BroadcastChannel('baidu_oauth_channel') bcBaidu.onmessage = handleMsg + const bcDropbox = new BroadcastChannel('dropbox_oauth_channel') + bcDropbox.onmessage = handleMsg + return () => { window.removeEventListener('message', handleMsg) bcGDrive.close() bcMs.close() bcBaidu.close() + bcDropbox.close() } } @@ -400,12 +462,15 @@ export function useBackupProviders() { isAuthenticatingGoogle, isAuthenticatingMicrosoft, isAuthenticatingBaidu, + isAuthenticatingDropbox, authStatusGoogle, authStatusMicrosoft, authStatusBaidu, + authStatusDropbox, authErrorMessageGoogle, authErrorMessageMicrosoft, authErrorMessageBaidu, + authErrorMessageDropbox, form, isAutoBackupPasswordSaved, shouldUseExistingAutoBackupPassword, @@ -418,6 +483,7 @@ export function useBackupProviders() { startGoogleAuth, startMicrosoftAuth, startBaiduAuth, + startDropboxAuth, handleAuthMessage, setupAuthListener, availableTypes diff --git a/frontend/src/features/backup/service/backupService.js b/frontend/src/features/backup/service/backupService.js index 2b41cb3..738d6a4 100644 --- a/frontend/src/features/backup/service/backupService.js +++ b/frontend/src/features/backup/service/backupService.js @@ -221,5 +221,17 @@ export const backupService = { } catch (e) { throw new backupError('Failed to get Baidu Auth URL', 'AUTH_URL_FETCH_FAILED', e) } + }, + + /** + * 获取 Dropbox 授权地址 + * @returns {Promise<{success: boolean, authUrl: string}>} + */ + async getDropboxAuthUrl() { + try { + return await request('/api/backups/oauth/dropbox/auth', { method: 'POST' }) + } catch (e) { + throw new backupError('Failed to get Dropbox Auth URL', 'AUTH_URL_FETCH_FAILED', e) + } } } diff --git a/frontend/src/features/migration/views/dataImport.vue b/frontend/src/features/migration/views/dataImport.vue index 11303e3..cae5af2 100644 --- a/frontend/src/features/migration/views/dataImport.vue +++ b/frontend/src/features/migration/views/dataImport.vue @@ -25,22 +25,31 @@ - +
- -

{{ $t('migration.support_desc') }}

+ + {{ $t('migration.support_desc') }} +
+
-

📁 {{ $t('migration.system_backup_format') }}

+

+ + {{ $t('migration.system_backup_format') }} +

{{ $t('migration.encrypted_backup_json') }} {{ $t('migration.plaintext_backup_json') }}
-
-

📱 {{ $t('migration.mobile_app_format') }}

+ +
+

+ + {{ $t('migration.mobile_app_format') }} +

2FAS (.2fas) Aegis (.json/.txt) @@ -51,30 +60,36 @@ Microsoft Auth
- - + + -
- Google Authenticator +
+ Google Authenticator

{{ $t('migration.ga_auth_desc') }}

- {{ $t('migration.ms_auth_desc') }} -

{{ $t('migration.ms_auth_detail') }}:
- /data/data/com.azure.authenticator/databases/PhoneFactor
- /data/data/com.azure.authenticator/databases/PhoneFactor-wal
- /data/data/com.azure.authenticator/databases/PhoneFactor-shm -

+ Microsoft Authenticator +

{{ $t('migration.ms_auth_desc') }}

+

{{ $t('migration.ms_auth_detail') }}:

+ /data/data/com.azure.authenticator/databases/PhoneFactor + /data/data/com.azure.authenticator/databases/PhoneFactor-wal + /data/data/com.azure.authenticator/databases/PhoneFactor-shm
+
-

📄 {{ $t('migration.generic_format') }}

+

+ + {{ $t('migration.generic_format') }} +

{{ $t('migration.generic_json') }} OTPAuth URI (.txt) @@ -132,7 +147,7 @@