diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 844d8b8..3afa304 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -23,19 +23,19 @@ jobs: frontend/package.json backend/package.json - # 关键步骤:Wrangler 的 build 命令会调用前端构建,先进入 frontend 目录安装依赖,否则构建会失败。 + # 安装前端依赖 (Wrangler 构建需要) - name: Install Frontend Dependencies run: | cd frontend npm install - # 安装后端依赖 (包含 wrangler 等) + # 安装后端依赖 - name: Install Backend Dependencies run: | cd backend npm install - # 动态注入 D1 数据库ID 和 数据库名称 + # 注入 D1 数据库配置 - name: Inject D1 Database ID env: CLOUDFLARE_D1_DATABASE_ID: ${{ secrets.CLOUDFLARE_D1_DATABASE_ID }} @@ -45,7 +45,7 @@ jobs: sed -i "0,/\"database_id\":/s/\"database_id\": \".*\"/\"database_id\": \"$CLOUDFLARE_D1_DATABASE_ID\"/" backend/wrangler.jsonc sed -i "0,/\"database_name\":/s/\"database_name\": \".*\"/\"database_name\": \"$CLOUDFLARE_D1_DATABASE_NAME\"/" backend/wrangler.jsonc - # 初始化或更新数据库结构 (执行 schema.sql) + # 更新数据库结构 - name: Update Database Schema uses: cloudflare/wrangler-action@v3 with: @@ -54,14 +54,13 @@ jobs: workingDirectory: 'backend' command: d1 execute ${{ secrets.CLOUDFLARE_D1_DATABASE_NAME || '2fauth-db' }} --remote --file=schema.sql - # 使用官方 Action 进行部署 + # 部署到 Cloudflare Workers - name: Deploy to Cloudflare Workers uses: cloudflare/wrangler-action@v3 with: apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} workingDirectory: 'backend' - # 保持与 package.json 中一致的参数,开启压缩 command: deploy --minify secrets: | OAUTH_CLIENT_ID diff --git a/.gitignore b/.gitignore index 0b1333f..d6b4812 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,12 @@ backend/node_modules backend/package-lock.json frontend/.vscode +frontend/.github +frontend/.vite +frontend/.gitignore frontend/node_modules frontend/dist +frontend/tests +frontend/playwright-report +frontend/playwright.config.ts frontend/package-lock.json \ No newline at end of file diff --git a/backend/package.json b/backend/package.json index 0bd2660..7c1c2c0 100644 --- a/backend/package.json +++ b/backend/package.json @@ -6,6 +6,7 @@ "deploy": "wrangler deploy --minify src/index.ts" }, "dependencies": { + "aws4fetch": "^1.0.19", "hono": "^4.11.10", "webdav": "^5.9.0" }, diff --git a/backend/schema.sql b/backend/schema.sql index 162ed95..7049fcc 100644 --- a/backend/schema.sql +++ b/backend/schema.sql @@ -13,13 +13,17 @@ CREATE TABLE IF NOT EXISTS accounts ( updated_by TEXT ); --- WebDAV 配置表 -CREATE TABLE IF NOT EXISTS webdav_configs ( +-- 备份提供商配置表 (支持多账号/多类型) +DROP TABLE IF EXISTS webdav_configs; +CREATE TABLE IF NOT EXISTS backup_providers ( id INTEGER PRIMARY KEY AUTOINCREMENT, - url TEXT NOT NULL, - username TEXT NOT NULL, - password TEXT NOT NULL, -- 存储加密后的 JSON 字符串 - save_dir TEXT, + type TEXT NOT NULL, -- 类型: 'webdav', 's3', etc. + name TEXT NOT NULL, -- 别名: '我的坚果云' + is_enabled BOOLEAN DEFAULT 1,-- 开关 + config TEXT NOT NULL, -- JSON 字符串 (敏感字段如 password 需加密存储) + last_backup_at INTEGER, -- 最后备份时间 + last_backup_status TEXT, -- 'success' | 'failed' + created_at INTEGER, updated_at INTEGER ); diff --git a/backend/src/index.ts b/backend/src/index.ts index df0693e..f45e425 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -6,7 +6,7 @@ import { EnvBindings } from './config'; // 稍后我们会在这里引入拆分好的路由模块 import authRoutes from './routes/auth'; import accountsRoutes from './routes/accounts'; -import webdavRoutes from './routes/webdav'; +import backupRoutes from './routes/backups'; // 扩展 EnvBindings 以包含 ASSETS (Cloudflare Pages/Workers Assets) type Bindings = EnvBindings & { ASSETS: { fetch: (req: Request) => Promise } }; @@ -29,14 +29,14 @@ app.get('/api', (c) => c.text('🔐 2FA Secure Manager API is running!')); // 3. 挂载子路由 app.route('/api/oauth', authRoutes); app.route('/api/accounts', accountsRoutes); -app.route('/api/webdav', webdavRoutes); +app.route('/api/backups', backupRoutes); // 4. 全局错误处理 app.onError((err, c) => { const statusCode = (err as any).statusCode || (err as any).status || 500; // 特殊处理: WebDAV list 接口如果返回 404,说明目录不存在,视为无备份,返回空列表 - if (c.req.path.includes('/webdav/list') && (Number(statusCode) === 404 || err.message.includes('404'))) { + if (c.req.path.includes('/files') && (Number(statusCode) === 404 || err.message.includes('404'))) { return c.json({ success: true, backups: [] }); } diff --git a/backend/src/providers/BackupProvider.ts b/backend/src/providers/BackupProvider.ts new file mode 100644 index 0000000..715e9ed --- /dev/null +++ b/backend/src/providers/BackupProvider.ts @@ -0,0 +1,12 @@ +export interface BackupFile { + filename: string; + size: number; + lastModified: string; +} + +export interface BackupProvider { + testConnection(): Promise; + listBackups(): Promise; + uploadBackup(filename: string, data: string): Promise; + downloadBackup(filename: string): Promise; +} \ No newline at end of file diff --git a/backend/src/providers/S3Provider.ts b/backend/src/providers/S3Provider.ts new file mode 100644 index 0000000..fc0f7b0 --- /dev/null +++ b/backend/src/providers/S3Provider.ts @@ -0,0 +1,102 @@ +import { AwsClient } from 'aws4fetch'; +import { BackupProvider, BackupFile } from './BackupProvider'; + +export class S3Provider implements BackupProvider { + private client: AwsClient; + private bucket: string; + private endpoint: string; + private prefix: string; + + constructor(config: any) { + if (!config.endpoint || !config.accessKeyId || !config.secretAccessKey || !config.bucket) { + throw new Error('S3 configuration incomplete'); + } + + this.client = new AwsClient({ + accessKeyId: config.accessKeyId, + secretAccessKey: config.secretAccessKey, + region: config.region || 'auto', + service: 's3', + }); + + this.bucket = config.bucket; + // 移除 endpoint 末尾的斜杠 + this.endpoint = config.endpoint.replace(/\/+$/, ''); + + // 处理前缀 (目录),确保不以 / 开头,但以 / 结尾 + this.prefix = (config.saveDir || '').trim(); + if (this.prefix.startsWith('/')) this.prefix = this.prefix.slice(1); + if (this.prefix.length > 0 && !this.prefix.endsWith('/')) this.prefix += '/'; + } + + // 构造 Path-Style URL: endpoint/bucket/key + // 这种方式兼容性最好 (支持 R2, MinIO 等) + private getUrl(key: string = '', params: Record = {}): string { + const url = new URL(`${this.endpoint}/${this.bucket}/${key}`); + Object.entries(params).forEach(([k, v]) => url.searchParams.append(k, v)); + return url.toString(); + } + + async testConnection(): Promise { + // 使用 list-type=2 获取少量文件来测试权限和连通性 + 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}`); + } + return true; + } + + 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}`); + + const text = await res.text(); + + // 简单的 XML 解析 (避免引入庞大的 XML 解析库) + // 提取 块中的 Key, LastModified, Size + const backups: BackupFile[] = []; + const regex = /[\s\S]*?(.*?)<\/Key>[\s\S]*?(.*?)<\/LastModified>[\s\S]*?(.*?)<\/Size>[\s\S]*?<\/Contents>/g; + + let match; + while ((match = regex.exec(text)) !== null) { + const fullKey = match[1]; + const lastModified = match[2]; + const size = parseInt(match[3], 10); + + // 过滤掉非 JSON 备份文件 + const filename = fullKey.replace(this.prefix, ''); + if (filename.startsWith('2fa-backup-') && filename.endsWith('.json')) { + backups.push({ + filename: filename, + size: size, + lastModified: lastModified + }); + } + } + + return backups.sort((a, b) => b.filename.localeCompare(a.filename)); + } + + async uploadBackup(filename: string, data: string): Promise { + const key = this.prefix + filename; + const url = this.getUrl(key); + + const res = await this.client.fetch(url, { + method: 'PUT', + body: data, + headers: { 'Content-Type': 'application/json' } + }); + + 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}`); + return await res.text(); + } +} \ No newline at end of file diff --git a/backend/src/providers/WebDavProvider.ts b/backend/src/providers/WebDavProvider.ts new file mode 100644 index 0000000..ed0eddd --- /dev/null +++ b/backend/src/providers/WebDavProvider.ts @@ -0,0 +1,62 @@ +import { createClient, WebDAVClient } from 'webdav'; +import { BackupProvider, BackupFile } from './BackupProvider'; + +export class WebDavProvider implements BackupProvider { + private client: WebDAVClient; + private saveDir: string; + + constructor(config: any) { + if (!config.url || !config.username || !config.password) { + throw new Error('WebDAV configuration incomplete'); + } + this.client = createClient(config.url, { + username: config.username, + password: config.password + }); + + // 标准化 saveDir: 确保以 / 开头,且不以 / 结尾 (除非是根目录) + let dir = (config.saveDir || '/').trim(); + if (!dir.startsWith('/')) dir = '/' + dir; + if (dir.length > 1 && dir.endsWith('/')) dir = dir.slice(0, -1); + + this.saveDir = dir; + } + + async testConnection(): Promise { + await this.client.getDirectoryContents('/'); + return true; + } + + async listBackups(): Promise { + const items = await this.client.getDirectoryContents(this.saveDir); + return (items as any[]) + .filter(item => item.type === 'file' && item.basename.startsWith('2fa-backup-') && item.basename.endsWith('.json')) + .map(item => { + // Try to parse date from filename if lastmod is missing or generic + let displayTime = item.lastmod; + return { + filename: item.basename, + path: item.filename, // 返回 WebDAV 服务器提供的绝对路径 + size: item.size, + lastModified: displayTime + }; + }) + .sort((a, b) => b.filename.localeCompare(a.filename)); + } + + async uploadBackup(filename: string, data: string): Promise { + if (this.saveDir !== '/' && await this.client.exists(this.saveDir) === false) { + await this.client.createDirectory(this.saveDir); + } + const fullPath = this.saveDir === '/' ? `/${filename}` : `${this.saveDir}/${filename}`; + await this.client.putFileContents(fullPath, data); + } + + async downloadBackup(filename: string): Promise { + const cleanFilename = filename.trim().replace(/^\/+/, ''); + const fullPath = this.saveDir === '/' ? `/${cleanFilename}` : `${this.saveDir}/${cleanFilename}`; + + const content = await this.client.getFileContents(fullPath, { format: 'text' }); + return content as string; + } +} \ No newline at end of file diff --git a/backend/src/routes/accounts.ts b/backend/src/routes/accounts.ts index bba337a..d91030d 100644 --- a/backend/src/routes/accounts.ts +++ b/backend/src/routes/accounts.ts @@ -2,33 +2,14 @@ import { Hono } from 'hono'; import { EnvBindings, AppError, SECURITY_CONFIG } from '../config'; import { authMiddleware, sanitizeInput, validateBase32Secret, validateServiceName, validateAccountName, parseOTPAuthURI } from '../utils/helper'; import { encryptData, decryptData, generateTOTP } from '../utils/crypto'; +import { encryptField, decryptField, batchInsertAccounts, batchDeleteAccounts } from '../utils/db'; -// 定义带有环境变量和用户变量的 Hono 实例 const accounts = new Hono<{ Bindings: EnvBindings, Variables: { user: any } }>(); -// 🛡️ 为这下面所有的路由挂载鉴权中间件 (必须登录才能访问) +// 挂载鉴权中间件 accounts.use('*', authMiddleware); -// 辅助:加密单个字段并序列化为字符串存储 -async function encryptField(data: any, key: string) { - const encrypted = await encryptData(data, key); - return JSON.stringify(encrypted); -} - -// 辅助:反序列化并解密单个字段 -async function decryptField(encryptedStr: string, key: string) { - try { - const encryptedObj = JSON.parse(encryptedStr); - return await decryptData(encryptedObj, key); - } catch (e) { - console.error('Decryption failed', e); - return null; - } -} - -// ========================================== -// 1. 获取所有账号 -// ========================================== +// 获取所有账号 accounts.get('/', async (c) => { const pageStr = c.req.query('page'); const limitStr = c.req.query('limit'); @@ -59,13 +40,11 @@ accounts.get('/', async (c) => { const accountsList = await Promise.all(results.map(async (row: any) => { const secret = await decryptField(row.secret, key); - // 🚀 [自动迁移] 检查是否为旧格式 (含 salt),如果是则异步升级为极速格式 + // [自动迁移] 旧格式 (含 salt) 异步升级为极速格式 try { const raw = JSON.parse(row.secret); if (raw.salt && raw.salt.length > 0) { - // 重新加密 (encryptField 现已使用极速模式) const newSecret = await encryptField(secret, key); - // 异步更新数据库 (不阻塞响应) c.executionCtx.waitUntil( c.env.DB.prepare("UPDATE accounts SET secret = ? WHERE id = ?") .bind(newSecret, row.id) @@ -98,9 +77,7 @@ accounts.get('/', async (c) => { return c.json(response); }); -// ========================================== -// 2. 添加新账号 -// ========================================== +// 添加新账号 accounts.post('/', async (c) => { const { service, category, account, secret, digits = 6, period = 30 } = await c.req.json(); const user = c.get('user'); @@ -112,7 +89,7 @@ accounts.post('/', async (c) => { throw new AppError('Invalid digits or period', 400); } - // D1: 查重 + // 查重 const existing = await c.env.DB.prepare( "SELECT id FROM accounts WHERE service = ? AND account = ?" ).bind(service, account).first(); @@ -123,7 +100,7 @@ accounts.post('/', async (c) => { const key = c.env.ENCRYPTION_KEY || c.env.JWT_SECRET; const secretEncrypted = await encryptField(secret.replace(/\s/g, '').toUpperCase(), key); - // D1: 插入 + // 插入 await c.env.DB.prepare( `INSERT INTO accounts (id, service, account, category, secret, digits, period, created_at, created_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)` @@ -137,9 +114,7 @@ accounts.post('/', async (c) => { return c.json({ success: true, account: { ...newAccount, secret: '[PROTECTED]' } }); }); -// ========================================== -// 3. 修改账号信息 -// ========================================== +// 修改账号信息 accounts.put('/:id', async (c) => { const id = c.req.param('id'); const { service, category, account, digits, period } = await c.req.json(); @@ -165,9 +140,7 @@ accounts.put('/:id', async (c) => { return c.json({ success: true, message: 'Account updated successfully' }); }); -// ========================================== -// 4. 删除指定账号 -// ========================================== +// 删除指定账号 accounts.delete('/:id', async (c) => { const id = c.req.param('id'); const result = await c.env.DB.prepare("DELETE FROM accounts WHERE id = ?").bind(id).run(); @@ -177,17 +150,22 @@ accounts.delete('/:id', async (c) => { return c.json({ success: true, message: 'Account deleted successfully' }); }); -// ========================================== -// 5. 清空所有账号 -// ========================================== +// 批量删除账号 +accounts.post('/batch-delete', async (c) => { + const { ids } = await c.req.json(); + if (!Array.isArray(ids) || ids.length === 0) throw new AppError('No IDs provided', 400); + + const count = await batchDeleteAccounts(c.env.DB, ids); + return c.json({ success: true, count, message: `Successfully deleted ${count} accounts` }); +}); + +// 清空所有账号 accounts.delete('/clear-all', async (c) => { await c.env.DB.prepare("DELETE FROM accounts").run(); return c.json({ success: true, message: 'All accounts cleared' }); }); -// ========================================== -// 6. 核心功能:生成 TOTP 验证码 -// ========================================== +// 生成 TOTP 验证码 accounts.post('/generate-totp', async (c) => { const { secret, period = 30, digits = 6 } = await c.req.json(); @@ -198,9 +176,7 @@ accounts.post('/generate-totp', async (c) => { return c.json({ success: true, code }); }); -// ========================================== -// 7. 扫码支持:解析 TOTP URI -// ========================================== +// 解析 TOTP URI accounts.post('/parse-uri', async (c) => { const { uri } = await c.req.json(); if (!uri) throw new AppError('URI is required', 400); @@ -211,9 +187,7 @@ accounts.post('/parse-uri', async (c) => { return c.json({ success: true, account }); }); -// ========================================== -// 8. 从扫码结果添加账户 (修复架构师遗漏的接口) -// ========================================== +// 从 URI 添加账户 accounts.post('/add-from-uri', async (c) => { const { uri, category } = await c.req.json(); const user = c.get('user'); @@ -223,7 +197,7 @@ accounts.post('/add-from-uri', async (c) => { const parsedAccount = parseOTPAuthURI(uri); if (!parsedAccount) throw new AppError('Invalid OTP Auth URI', 400); - // D1: 查重 + // 查重 const existing = await c.env.DB.prepare( "SELECT id FROM accounts WHERE service = ? AND account = ?" ).bind(parsedAccount.issuer, parsedAccount.account).first(); @@ -246,9 +220,7 @@ accounts.post('/add-from-uri', async (c) => { return c.json({ success: true, account: { ...newAccount, secret: '[PROTECTED]' } }); }); -// ========================================== -// 9. 加密导出金库数据 -// ========================================== +// 加密导出数据 accounts.post('/export-secure', async (c) => { const { password } = await c.req.json(); if (!password || password.length < SECURITY_CONFIG.MIN_EXPORT_PASSWORD_LENGTH) { @@ -288,9 +260,7 @@ accounts.post('/export-secure', async (c) => { return c.json(exportFile); }); -// ========================================== -// 10. 全能导入通道 (支持 加密/JSON/2FAS/文本) -// ========================================== +// 导入数据 (支持 加密/JSON/2FAS/文本) accounts.post('/import', async (c) => { const { content, type, password } = await c.req.json(); const user = c.get('user'); @@ -344,42 +314,35 @@ accounts.post('/import', async (c) => { } // 过滤有效账户并去重保存 - let addedCount = 0; const key = c.env.ENCRYPTION_KEY || c.env.JWT_SECRET; - // D1 批量插入 (使用事务或批量执行) - const stmt = c.env.DB.prepare( - `INSERT INTO accounts (id, service, account, category, secret, digits, period, created_at, created_by) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)` - ); - const batch = []; + // 1. 预先获取所有已存在的账号标识,避免 N+1 查询 + const { results: existingRows } = await c.env.DB.prepare("SELECT service, account FROM accounts").all(); + // 内存中快速查重 (格式: service:account) + const existingSet = new Set(existingRows.map((row: any) => `${row.service}:${row.account}`)); + + // 2. 内存过滤与并行加密 + const uniqueAccountsToInsert = []; + const seenInBatch = new Set(); for (const acc of validAccounts) { if (acc.service && acc.account && validateBase32Secret(acc.secret)) { - // 简单查重 - const existing = await c.env.DB.prepare("SELECT id FROM accounts WHERE service = ? AND account = ?") - .bind(acc.service, acc.account).first(); - - if (!existing) { - const secretEncrypted = await encryptField(acc.secret.replace(/\s/g, '').toUpperCase(), key); - batch.push(stmt.bind( - crypto.randomUUID(), sanitizeInput(acc.service, 50), sanitizeInput(acc.account, 100), - acc.category ? sanitizeInput(acc.category, 30) : '', secretEncrypted, - acc.digits || 6, acc.period || 30, Date.now(), user.username - )); - addedCount++; + const signature = `${acc.service}:${acc.account}`; + // 查重:既不在数据库中,也不在当前待插入的批次中 + if (!existingSet.has(signature) && !seenInBatch.has(signature)) { + uniqueAccountsToInsert.push(acc); + seenInBatch.add(signature); } } } - if (batch.length > 0) await c.env.DB.batch(batch); + // 3. 使用封装好的批量插入函数 + const insertedCount = await batchInsertAccounts(c.env.DB, uniqueAccountsToInsert, key, user.username); - return c.json({ success: true, count: addedCount, total: validAccounts.length, duplicates: validAccounts.length - addedCount }); + return c.json({ success: true, count: insertedCount, total: validAccounts.length, duplicates: validAccounts.length - insertedCount }); }); -// ========================================== -// 11. 手动触发数据迁移 (旧格式 -> 极速格式) -// ========================================== +// 手动触发数据迁移 (旧格式 -> 极速格式) accounts.post('/migrate-crypto', async (c) => { const key = c.env.ENCRYPTION_KEY || c.env.JWT_SECRET; const limit = 50; // 每次处理 50 条以防超时 @@ -393,18 +356,24 @@ accounts.post('/migrate-crypto', async (c) => { return c.json({ success: true, message: '所有数据已是最新格式', migrated: 0, remaining: 0 }); } - const updates = []; - for (const row of results) { + // 并行处理解密和加密 + const updates = (await Promise.all(results.map(async (row: any) => { try { const plain = await decryptField(row.secret as string, key); const newSecret = await encryptField(plain, key); - updates.push( - c.env.DB.prepare("UPDATE accounts SET secret = ? WHERE id = ?").bind(newSecret, row.id) - ); - } catch (e) { console.error(`Failed to migrate account ${row.id}`, e); } - } + return c.env.DB.prepare("UPDATE accounts SET secret = ? WHERE id = ?").bind(newSecret, row.id); + } catch (e) { + console.error(`Failed to migrate account ${row.id}`, e); + return null; + } + }))).filter(Boolean); - if (updates.length > 0) await c.env.DB.batch(updates); + // 分批执行更新 + const BATCH_SIZE = 20; + for (let i = 0; i < updates.length; i += BATCH_SIZE) { + const chunk = updates.slice(i, i + BATCH_SIZE); + if (chunk.length > 0) await c.env.DB.batch(chunk); + } // 检查剩余数量 const remaining = await c.env.DB.prepare( diff --git a/backend/src/routes/backups.ts b/backend/src/routes/backups.ts new file mode 100644 index 0000000..7037508 --- /dev/null +++ b/backend/src/routes/backups.ts @@ -0,0 +1,257 @@ +import { Hono } from 'hono'; +import { EnvBindings, AppError, SECURITY_CONFIG } from '../config'; +import { authMiddleware, sanitizeInput } from '../utils/helper'; +import { encryptData, decryptData } from '../utils/crypto'; +import { BackupProvider } from '../providers/BackupProvider'; +import { WebDavProvider } from '../providers/WebDavProvider'; +import { S3Provider } from '../providers/S3Provider'; +import { batchInsertAccounts, decryptField } from '../utils/db'; + +const backups = new Hono<{ Bindings: EnvBindings, Variables: { user: any } }>(); + +backups.use('*', authMiddleware); + +// 工厂函数:根据类型获取 Provider 实例 +async function getProvider(type: string, config: any): Promise { + switch (type) { + case 'webdav': + return new WebDavProvider(config); + case 's3': + return new S3Provider(config); + default: + throw new AppError(`Unknown provider type: ${type}`, 400); + } +} + +// 辅助函数:处理配置存储(加密敏感字段) +async function processConfigForStorage(type: string, config: any, key: string) { + const processed = { ...config }; + if (type === 'webdav') { + if (processed.password) { + processed.password = await encryptData(processed.password, key); + } + } + if (type === 's3') { + if (processed.secretAccessKey) { + processed.secretAccessKey = await encryptData(processed.secretAccessKey, key); + } + } + return JSON.stringify(processed); +} + +// 辅助函数:处理配置读取(解密敏感字段) +async function processConfigForUsage(type: string, configStr: string, key: string) { + const config = JSON.parse(configStr); + if (type === 'webdav') { + if (config.password) { + config.password = await decryptData(config.password, key); + } + } + if (type === 's3') { + if (config.secretAccessKey) { + config.secretAccessKey = await decryptData(config.secretAccessKey, key); + } + } + return config; +} + +// ========================================== +// 1. 获取所有备份源列表 +// ========================================== +backups.get('/providers', async (c) => { + const { results } = await c.env.DB.prepare("SELECT * FROM backup_providers ORDER BY created_at DESC").all(); + + const key = c.env.ENCRYPTION_KEY || c.env.JWT_SECRET; + const providers = await Promise.all(results.map(async (row: any) => { + // 解密配置返回给前端(以便编辑回显),注意前端展示时要掩码处理 + const config = await processConfigForUsage(row.type, row.config, key); + return { + ...row, + config + }; + })); + + return c.json({ success: true, providers }); +}); + +// ========================================== +// 2. 添加备份源 +// ========================================== +backups.post('/providers', async (c) => { + const { type, name, config } = await c.req.json(); + + if (!type || !name || !config) throw new AppError('Missing required fields', 400); + + const key = c.env.ENCRYPTION_KEY || c.env.JWT_SECRET; + const encryptedConfig = await processConfigForStorage(type, config, key); + + const res = await c.env.DB.prepare( + "INSERT INTO backup_providers (type, name, config, created_at, updated_at) VALUES (?, ?, ?, ?, ?)" + ).bind(type, name, encryptedConfig, Date.now(), Date.now()).run(); + + return c.json({ success: true, id: res.meta.last_row_id }); +}); + +// ========================================== +// 3. 更新备份源 +// ========================================== +backups.put('/providers/:id', async (c) => { + const id = c.req.param('id'); + const { name, config, type } = await c.req.json(); + + const key = c.env.ENCRYPTION_KEY || c.env.JWT_SECRET; + const encryptedConfig = await processConfigForStorage(type, config, key); + + await c.env.DB.prepare( + "UPDATE backup_providers SET name = ?, config = ?, updated_at = ? WHERE id = ?" + ).bind(name, encryptedConfig, Date.now(), id).run(); + + return c.json({ success: true }); +}); + +// ========================================== +// 4. 删除备份源 +// ========================================== +backups.delete('/providers/:id', async (c) => { + const id = c.req.param('id'); + await c.env.DB.prepare("DELETE FROM backup_providers WHERE id = ?").bind(id).run(); + return c.json({ success: true }); +}); + +// ========================================== +// 5. 测试连接 +// ========================================== +backups.post('/providers/test', async (c) => { + const { type, config } = await c.req.json(); + try { + const provider = await getProvider(type, config); + await provider.testConnection(); + return c.json({ success: true, message: 'Connection successful' }); + } catch (e: any) { + throw new AppError(`Connection failed: ${e.message}`, 400); + } +}); + +// ========================================== +// 6. 执行备份 (导出) +// ========================================== +backups.post('/providers/:id/backup', async (c) => { + const id = c.req.param('id'); + const { password } = await c.req.json(); // 用户输入的加密密码 + + if (!password || password.length < SECURITY_CONFIG.MIN_EXPORT_PASSWORD_LENGTH) { + throw new AppError(`Export password must be at least ${SECURITY_CONFIG.MIN_EXPORT_PASSWORD_LENGTH} chars`, 400); + } + + // 1. 获取 Provider 配置 + const providerRow: any = await c.env.DB.prepare("SELECT * FROM backup_providers WHERE id = ?").bind(id).first(); + if (!providerRow) throw new AppError('Provider not found', 404); + + const key = c.env.ENCRYPTION_KEY || c.env.JWT_SECRET; + const config = await processConfigForUsage(providerRow.type, providerRow.config, key); + const provider = await getProvider(providerRow.type, config); + + // 2. 准备数据 (从数据库读取并解密) + const { results: accountResults } = await c.env.DB.prepare("SELECT * FROM accounts").all(); + + // 优化:并行解密,并过滤掉解密失败的数据,防止因单条数据损坏导致整个备份失败 + const accounts = (await Promise.all(accountResults.map(async (row: any) => { + const secret = await decryptField(row.secret, key); + if (!secret) return null; // 如果某条数据解密失败(如密钥轮换遗留的旧数据),则跳过,不影响整体备份 + + return { + service: row.service, + account: row.account, + category: row.category, + secret: secret, + digits: row.digits, + period: row.period + }; + }))).filter(Boolean); + + const exportPayload = { + version: "2.0", + app: "2fa-secure-manager", + encrypted: true, + timestamp: new Date().toISOString(), + accounts + }; + + // 3. 使用用户密码加密 Payload + const userEncrypted = await encryptData(exportPayload, password); + const fileContent = JSON.stringify({ ...exportPayload, data: userEncrypted, accounts: undefined }); + + // 4. 上传 + const filename = `2fa-backup-${new Date().toISOString().replace(/[:.]/g, '-')}.json`; + try { + await provider.uploadBackup(filename, fileContent); + + // 更新状态 + await c.env.DB.prepare("UPDATE backup_providers SET last_backup_at = ?, last_backup_status = 'success' WHERE id = ?") + .bind(Date.now(), id).run(); + + return c.json({ success: true, message: 'Backup successful' }); + } catch (e: any) { + await c.env.DB.prepare("UPDATE backup_providers SET last_backup_status = 'failed' WHERE id = ?") + .bind(id).run(); + throw new AppError(`Backup failed: ${e.message}`, 500); + } +}); + +// ========================================== +// 7. 获取文件列表 & 恢复备份 +// ========================================== +backups.get('/providers/:id/files', async (c) => { + const id = c.req.param('id'); + const providerRow: any = await c.env.DB.prepare("SELECT * FROM backup_providers WHERE id = ?").bind(id).first(); + if (!providerRow) throw new AppError('Provider not found', 404); + + const key = c.env.ENCRYPTION_KEY || c.env.JWT_SECRET; + const config = await processConfigForUsage(providerRow.type, providerRow.config, key); + const provider = await getProvider(providerRow.type, config); + + const files = await provider.listBackups(); + return c.json({ success: true, files }); +}); + +backups.post('/providers/:id/restore', async (c) => { + const id = c.req.param('id'); + const { filename, password } = await c.req.json(); + + const providerRow: any = await c.env.DB.prepare("SELECT * FROM backup_providers WHERE id = ?").bind(id).first(); + if (!providerRow) throw new AppError('Provider not found', 404); + + const key = c.env.ENCRYPTION_KEY || c.env.JWT_SECRET; + const config = await processConfigForUsage(providerRow.type, providerRow.config, key); + const provider = await getProvider(providerRow.type, config); + + // 1. 下载文件 + let content: string; + try { + content = await provider.downloadBackup(filename); + } catch (e: any) { + throw new AppError(`Restore download failed: ${e.message}`, 500); + } + + // 2. 解析并解密 + let accounts = []; + try { + const backupFile = JSON.parse(content); + if (!backupFile.data) throw new Error('Legacy format or invalid file'); + + const decrypted = await decryptData(backupFile.data, password); // 使用用户输入的密码解密 + accounts = decrypted.accounts || []; + } catch (e: any) { + throw new AppError('解密失败:密码错误或文件格式不兼容', 400); + } + + // 3. 覆盖写入数据库 (先清空,再分批插入) + await c.env.DB.prepare("DELETE FROM accounts").run(); + + // 4. 使用封装好的批量插入函数 + const insertedCount = await batchInsertAccounts(c.env.DB, accounts, key, 'restore'); + + return c.json({ success: true, message: 'Restore successful', count: insertedCount }); +}); + +export default backups; diff --git a/backend/src/routes/webdav.ts b/backend/src/routes/webdav.ts deleted file mode 100644 index 9f0f983..0000000 --- a/backend/src/routes/webdav.ts +++ /dev/null @@ -1,310 +0,0 @@ -import { Hono } from 'hono'; -import { createClient } from 'webdav'; -import { EnvBindings, AppError, SECURITY_CONFIG } from '../config'; -import { authMiddleware, sanitizeInput } from '../utils/helper'; -import { encryptData, decryptData } from '../utils/crypto'; - -const webdav = new Hono<{ Bindings: EnvBindings, Variables: { user: any } }>(); - -// 🛡️ 挂载鉴权中间件 -webdav.use('*', authMiddleware); - -// 辅助:加密单个字段并序列化为字符串存储 -async function encryptField(data: any, key: string) { - const encrypted = await encryptData(data, key); - return JSON.stringify(encrypted); -} - -// 辅助:反序列化并解密单个字段 -async function decryptField(encryptedStr: string, key: string) { - try { - const encryptedObj = JSON.parse(encryptedStr); - return await decryptData(encryptedObj, key); - } catch (e) { - console.error('Decryption failed', e); - return null; - } -} - -// 内部辅助:创建 WebDAV 客户端 -function getWebDavClient(config: any) { - if (!config || !config.url || !config.username || !config.password) { - throw new AppError('WebDAV configuration is incomplete', 400); - } - return createClient(config.url, { - username: config.username, - password: config.password - }); -} - -// 内部辅助:生成备份文件名 -function generateBackupFilename(dir: string) { - const date = new Date().toISOString().replace(/[:.]/g, '-'); - const cleanDir = dir.endsWith('/') ? dir.slice(0, -1) : dir; - return `${cleanDir}/2fa-backup-${date}.json`; -} - -// ========================================== -// 1. 获取 WebDAV 配置 (解密后返回给前端) -// ========================================== -webdav.get('/configs', async (c) => { - const { results } = await c.env.DB.prepare("SELECT * FROM webdav_configs LIMIT 1").all(); - if (results.length === 0) return c.json({ success: true, configs: [] }); - - const row: any = results[0]; - const key = c.env.ENCRYPTION_KEY || c.env.JWT_SECRET; - const password = await decryptField(row.password, key); - - // 🚀 [自动迁移] 升级 WebDAV 密码加密格式 - try { - const raw = JSON.parse(row.password); - if (raw.salt && raw.salt.length > 0) { - const newPass = await encryptField(password, key); - c.executionCtx.waitUntil( - c.env.DB.prepare("UPDATE webdav_configs SET password = ? WHERE id = ?").bind(newPass, row.id).run() - ); - } - } catch (e) {} - - const configs = [{ - url: row.url, - username: row.username, - password: password, - saveDir: row.save_dir - }]; - return c.json({ success: true, configs }); -}); - -// ========================================== -// 2. 保存 WebDAV 配置 (加密后存入 KV) -// ========================================== -webdav.post('/configs', async (c) => { - const { configs } = await c.req.json(); - - if (!Array.isArray(configs)) { - throw new AppError('Invalid format: configs must be an array', 400); - } - - const config = configs[0]; - const key = c.env.ENCRYPTION_KEY || c.env.JWT_SECRET; - const passwordEncrypted = await encryptField(config.password, key); - - await c.env.DB.batch([ - c.env.DB.prepare("DELETE FROM webdav_configs"), - c.env.DB.prepare("INSERT INTO webdav_configs (url, username, password, save_dir, updated_at) VALUES (?, ?, ?, ?, ?)").bind(config.url, config.username, passwordEncrypted, config.saveDir, Date.now()) - ]); - - return c.json({ success: true, message: 'Configuration saved securely' }); -}); - -// ========================================== -// 3. 测试连接 (示例桩代码) -// ========================================== -webdav.post('/test', async (c) => { - // 前端通常会把当前填写的配置传过来测试,所以这里直接用 body 里的 - const config = await c.req.json(); - - try { - const client = getWebDavClient(config); - // 尝试读取根目录来验证凭证 - await client.getDirectoryContents('/'); - return c.json({ success: true, message: 'Connection successful' }); - } catch (error: any) { - console.error('WebDAV Test Error:', error); - throw new AppError(`Connection failed: ${error.message}`, 400); - } -}); - -// ========================================== -// 4. 获取备份文件列表 -// ========================================== -webdav.post('/list', async (c) => { - const { results } = await c.env.DB.prepare("SELECT * FROM webdav_configs LIMIT 1").all(); - if (results.length === 0) throw new AppError('No WebDAV config found', 404); - - const row: any = results[0]; - const key = c.env.ENCRYPTION_KEY || c.env.JWT_SECRET; - const password = await decryptField(row.password, key); - - // 🚀 [自动迁移] 升级 WebDAV 密码加密格式 (在 list 接口也做检查) - try { - const raw = JSON.parse(row.password); - if (raw.salt && raw.salt.length > 0) { - const newPass = await encryptField(password, key); - c.executionCtx.waitUntil( - c.env.DB.prepare("UPDATE webdav_configs SET password = ? WHERE id = ?").bind(newPass, row.id).run() - ); - } - } catch (e) {} - - const configs = [{ - url: row.url, - username: row.username, - password: password, - saveDir: row.save_dir || '/' - }]; - - if (configs.length === 0) throw new AppError('No WebDAV config found', 404); - - // 默认使用第一个配置 - const config = configs[0]; - const client = getWebDavClient(config); - - try { - const items = await client.getDirectoryContents(config.saveDir); - // 过滤出我们的备份文件 - const backups = (items as any[]) - .filter(item => item.type === 'file' && item.basename.startsWith('2fa-backup-') && item.basename.endsWith('.json')) - .map(item => { - // 尝试获取时间:优先用服务器返回的 lastmod,如果没有则尝试从文件名解析 (文件名包含 ISO 时间戳) - let displayTime = item.lastmod; - if (!displayTime) { - try { - // 文件名格式: 2fa-backup-2024-03-20T10-00-00-000Z.json - // 简单提取中间的时间部分用于显示 - const match = item.basename.match(/2fa-backup-(.+)\.json/); - if (match) displayTime = match[1].replace(/-/g, ':').replace('T', ' '); - } catch (e) {} - } - - return { - name: item.basename, - filename: item.basename, // 兼容前端可能使用的 filename 字段 - size: item.size, - lastMod: displayTime, - lastModified: displayTime // 兼容前端可能使用的 lastModified 字段 - }; - }) - .sort((a, b) => b.name.localeCompare(a.name)); // 按时间倒序 - - return c.json({ success: true, backups }); - } catch (error: any) { - throw new AppError(`Failed to list backups: ${error.message}`, 500); - } -}); - -// ========================================== -// 5. 导出当前金库到 WebDAV -// ========================================== -webdav.post('/export', async (c) => { - const { password } = await c.req.json(); - if (!password || password.length < SECURITY_CONFIG.MIN_EXPORT_PASSWORD_LENGTH) { - throw new AppError(`导出密码至少需要 ${SECURITY_CONFIG.MIN_EXPORT_PASSWORD_LENGTH} 个字符`, 400); - } - - // 获取 WebDAV 配置 - const { results: configResults } = await c.env.DB.prepare("SELECT * FROM webdav_configs LIMIT 1").all(); - if (configResults.length === 0) throw new AppError('No WebDAV config found', 404); - const configRow: any = configResults[0]; - const key = c.env.ENCRYPTION_KEY || c.env.JWT_SECRET; - const webdavPassword = await decryptField(configRow.password, key); - const webdavConfig = { url: configRow.url, username: configRow.username, password: webdavPassword, saveDir: configRow.save_dir || '/' }; - - // 1. 从 D1 获取并解密当前数据 - const { results: accountResults } = await c.env.DB.prepare("SELECT * FROM accounts").all(); - - const accounts = await Promise.all(accountResults.map(async (row: any) => ({ - service: row.service, - account: row.account, - category: row.category, - secret: await decryptField(row.secret, key), - digits: row.digits, - period: row.period - }))); - - // 2. 准备导出数据结构 (清洗数据,仅保留必要字段) - const exportPayload = { - version: "2.0", - app: "2fa-secure-manager", - encrypted: true, - timestamp: new Date().toISOString(), - accounts: accounts.map((acc: any) => ({ - service: acc.service, category: acc.category, account: acc.account, - secret: acc.secret, digits: acc.digits, period: acc.period - })) - }; - - // 3. 使用用户密码重新加密 - const userEncrypted = await encryptData(exportPayload, password); - const fileContent = JSON.stringify({ ...exportPayload, data: userEncrypted, accounts: undefined }); - - // 4. 上传到 WebDAV - const client = getWebDavClient(webdavConfig); - const filename = generateBackupFilename(webdavConfig.saveDir); - - // 尝试创建目录(如果不存在) - if (webdavConfig.saveDir !== '/' && await client.exists(webdavConfig.saveDir) === false) { - await client.createDirectory(webdavConfig.saveDir); - } - - await client.putFileContents(filename, fileContent); - - return c.json({ success: true, message: 'Backup uploaded successfully', filename }); -}); - -// ========================================== -// 6. 从 WebDAV 恢复备份 -// ========================================== -webdav.post('/restore', async (c) => { - let body; - try { - body = await c.req.json(); - } catch (e) { - throw new AppError('Invalid JSON body', 400); - } - - // 规范化参数 - const filename = body.filename || body.name; - const password = body.password; - - if (!filename) throw new AppError('Filename is required', 400); - if (!password) throw new AppError('Decryption password is required', 400); - - const { results: configResults } = await c.env.DB.prepare("SELECT * FROM webdav_configs LIMIT 1").all(); - if (configResults.length === 0) throw new AppError('No WebDAV config found', 404); - const configRow: any = configResults[0]; - const key = c.env.ENCRYPTION_KEY || c.env.JWT_SECRET; - const webdavPassword = await decryptField(configRow.password, key); - const saveDir = configRow.save_dir || '/'; - - const client = getWebDavClient({ url: configRow.url, username: configRow.username, password: webdavPassword }); - - // 1. 下载文件 - // 拼接完整路径,因为 filename 只是文件名 - const fullPath = saveDir.endsWith('/') ? `${saveDir}${filename}` : `${saveDir}/${filename}`; - const content = await client.getFileContents(fullPath, { format: 'text' }); - - // 2. 使用用户密码解密 - let accounts = []; - try { - const backupFile = JSON.parse(content as string); - // 兼容性检查:如果是旧版直接上传的 KV 数据(没有 data 字段),则无法用密码解密,直接报错 - if (!backupFile.data) throw new Error('Legacy format'); - - const decrypted = await decryptData(backupFile.data, password); - accounts = decrypted.accounts || []; - } catch (e) { - throw new AppError('解密失败:密码错误或文件格式不兼容', 400); - } - - // 3. 覆盖写入 D1 (先清空,再批量插入) - await c.env.DB.prepare("DELETE FROM accounts").run(); - - const stmt = c.env.DB.prepare( - `INSERT INTO accounts (id, service, account, category, secret, digits, period, created_at, created_by) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)` - ); - const batch = []; - for (const acc of accounts) { - const secretEncrypted = await encryptField(acc.secret, key); - batch.push(stmt.bind( - crypto.randomUUID(), sanitizeInput(acc.service, 50), sanitizeInput(acc.account, 100), - acc.category || '', secretEncrypted, acc.digits || 6, acc.period || 30, Date.now(), 'restore' - )); - } - if (batch.length > 0) await c.env.DB.batch(batch); - - return c.json({ success: true, message: 'Restore successful' }); -}); - -export default webdav; \ No newline at end of file diff --git a/backend/src/utils/db.ts b/backend/src/utils/db.ts new file mode 100644 index 0000000..52abb1e --- /dev/null +++ b/backend/src/utils/db.ts @@ -0,0 +1,109 @@ +import { D1Database } from '@cloudflare/workers-types'; +import { encryptData, decryptData } from './crypto'; +import { sanitizeInput } from './helper'; + +// 加密并序列化字段 +export async function encryptField(data: any, key: string) { + const encrypted = await encryptData(data, key); + return JSON.stringify(encrypted); +} + +// 反序列化并解密字段 +export async function decryptField(encryptedStr: string, key: string) { + try { + const encryptedObj = JSON.parse(encryptedStr); + return await decryptData(encryptedObj, key); + } catch (e) { + console.error('Decryption failed', e); + return null; + } +} + +/** + * 批量插入账号 (数据清洗 + 并行加密 + 分批写入) + * @param db D1 数据库实例 + * @param accounts 待插入的原始账号列表 + * @param key 加密密钥 + * @param createdBy 创建者标识 (username 或 'restore') + * @returns 成功插入的数量 + */ +export async function batchInsertAccounts( + db: D1Database, + accounts: any[], + key: string, + createdBy: string +): Promise { + // 1. 准备数据 (规范化、加密、生成ID) + const preparedItems = await Promise.all(accounts.map(async (acc) => { + // 规范化密钥 (去除空格,转大写) + const normalizedSecret = (acc.secret || '').replace(/\s/g, '').toUpperCase(); + const secretEncrypted = await encryptField(normalizedSecret, key); + + return { + id: crypto.randomUUID(), + service: sanitizeInput(acc.service, 50), + account: sanitizeInput(acc.account, 100), + category: acc.category ? sanitizeInput(acc.category, 30) : '', + secretEncrypted, + digits: acc.digits || 6, + period: acc.period || 30, + created_at: Date.now(), + created_by: createdBy + }; + })); + + // 2. 构造插入语句 + const stmt = db.prepare( + `INSERT INTO accounts (id, service, account, category, secret, digits, period, created_at, created_by) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)` + ); + + // 3. 分批写入 (每批 100 条) + const BATCH_SIZE = 100; + for (let i = 0; i < preparedItems.length; i += BATCH_SIZE) { + const chunk = preparedItems.slice(i, i + BATCH_SIZE); + const batch = chunk.map(item => stmt.bind( + item.id, item.service, item.account, item.category, item.secretEncrypted, + item.digits, item.period, item.created_at, item.created_by + )); + + if (batch.length > 0) await db.batch(batch); + } + + return preparedItems.length; +} + +/** + * 批量删除账号 (分批 + 并行) + * @param db D1 数据库实例 + * @param ids 待删除的账号 ID 列表 + * @returns 成功删除的数量 + */ +export async function batchDeleteAccounts( + db: D1Database, + ids: string[] +): Promise { + if (!ids || ids.length === 0) return 0; + + // 优化:使用 IN 子句 + db.batch() 并行执行,减少 SQL 解析与网络 RTT + const CHUNK_SIZE = 50; + let deletedCount = 0; + const statements = []; + + for (let i = 0; i < ids.length; i += CHUNK_SIZE) { + const chunk = ids.slice(i, i + CHUNK_SIZE); + const placeholders = chunk.map(() => '?').join(','); + const query = `DELETE FROM accounts WHERE id IN (${placeholders})`; + statements.push(db.prepare(query).bind(...chunk)); + } + + // D1 batch 限制每次最多 128 条语句 + const BATCH_LIMIT = 100; + for (let i = 0; i < statements.length; i += BATCH_LIMIT) { + const batchChunk = statements.slice(i, i + BATCH_LIMIT); + const results = await db.batch(batchChunk); + results.forEach(res => deletedCount += (res.meta.changes || 0)); + } + + return deletedCount; +} \ No newline at end of file diff --git a/frontend/package.json b/frontend/package.json index 8d5b285..993047d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -16,6 +16,8 @@ "vue-router": "^5.0.2" }, "devDependencies": { + "@playwright/test": "^1.58.2", + "@types/node": "^25.3.0", "@vitejs/plugin-vue": "^6.0.2", "vite": "^7.3.1" } diff --git a/frontend/src/components/AccountList.vue b/frontend/src/components/AccountList.vue new file mode 100644 index 0000000..0875cb1 --- /dev/null +++ b/frontend/src/components/AccountList.vue @@ -0,0 +1,341 @@ + + + + + \ No newline at end of file diff --git a/frontend/src/components/AddAccountManual.vue b/frontend/src/components/AddAccountManual.vue new file mode 100644 index 0000000..677c32e --- /dev/null +++ b/frontend/src/components/AddAccountManual.vue @@ -0,0 +1,100 @@ + + + + + \ No newline at end of file diff --git a/frontend/src/components/AddAccountScan.vue b/frontend/src/components/AddAccountScan.vue new file mode 100644 index 0000000..6526031 --- /dev/null +++ b/frontend/src/components/AddAccountScan.vue @@ -0,0 +1,70 @@ + + + + + \ No newline at end of file diff --git a/frontend/src/components/BackupSettings.vue b/frontend/src/components/BackupSettings.vue new file mode 100644 index 0000000..cc63b7f --- /dev/null +++ b/frontend/src/components/BackupSettings.vue @@ -0,0 +1,335 @@ + + + + + \ No newline at end of file diff --git a/frontend/src/components/DataBackup.vue b/frontend/src/components/DataBackup.vue new file mode 100644 index 0000000..a9a3970 --- /dev/null +++ b/frontend/src/components/DataBackup.vue @@ -0,0 +1,27 @@ + + + + + \ No newline at end of file diff --git a/frontend/src/components/DataExport.vue b/frontend/src/components/DataExport.vue new file mode 100644 index 0000000..104f6c0 --- /dev/null +++ b/frontend/src/components/DataExport.vue @@ -0,0 +1,89 @@ + + + + + \ No newline at end of file diff --git a/frontend/src/components/DataImport.vue b/frontend/src/components/DataImport.vue new file mode 100644 index 0000000..7407aff --- /dev/null +++ b/frontend/src/components/DataImport.vue @@ -0,0 +1,117 @@ + + + + + \ No newline at end of file diff --git a/frontend/src/components/DataMigration.vue b/frontend/src/components/DataMigration.vue deleted file mode 100644 index 3e71154..0000000 --- a/frontend/src/components/DataMigration.vue +++ /dev/null @@ -1,259 +0,0 @@ - - - - - \ No newline at end of file diff --git a/frontend/src/components/WebDavConfig.vue b/frontend/src/components/WebDavConfig.vue deleted file mode 100644 index 35ff9a1..0000000 --- a/frontend/src/components/WebDavConfig.vue +++ /dev/null @@ -1,303 +0,0 @@ - - - - - \ No newline at end of file diff --git a/frontend/src/views/Home.vue b/frontend/src/views/Home.vue index 768e8c9..a336423 100644 --- a/frontend/src/views/Home.vue +++ b/frontend/src/views/Home.vue @@ -1,6 +1,7 @@ \ No newline at end of file diff --git a/frontend/src/views/Login.vue b/frontend/src/views/Login.vue index 731c491..c5bec8f 100644 --- a/frontend/src/views/Login.vue +++ b/frontend/src/views/Login.vue @@ -45,14 +45,14 @@ const loading = ref(false) const handleGitHubLogin = async () => { loading.value = true try { - // 调用后端的授权接口 + // 获取 GitHub 授权链接 const response = await fetch('/api/oauth/authorize') const data = await response.json() if (data.success && data.authUrl) { - // 记录 state 防御 CSRF(可选,后端也有校验机制) + // 记录 state 防御 CSRF localStorage.setItem('oauth_state', data.state) - // 直接把浏览器跳转到 GitHub 授权页 + // 跳转至 GitHub window.location.href = data.authUrl } else { ElMessage.error(data.error || '获取授权链接失败')