feat: 云端备份增加通过“电子邮箱SMTP”发信备份

This commit is contained in:
nap0o committed 2026-03-13 01:08:59 +08:00
1 parent 52c9b0a454
commit 9b49dade41
12 files changed
+687 -24

No files matched your search

+3 -1
View File
@@ -4,7 +4,7 @@
"scripts": {
"dev": "wrangler dev --config ../wrangler.dev.toml --ip 0.0.0.0 --port 8787 --test-scheduled",
"deploy": "wrangler deploy --minify ./src/app/worker.ts",
"build:docker": "esbuild ./src/app/server.ts --bundle --platform=node --external:better-sqlite3 --outfile=dist/server.js"
"build:docker": "esbuild ./src/app/server.ts --bundle --platform=node --external:better-sqlite3 --external:nodemailer --external:cloudflare:sockets --outfile=dist/server.js"
},
"dependencies": {
"@hono/node-server": "^1.19.11",
@@ -14,6 +14,7 @@
"drizzle-orm": "^0.30.10",
"hono": "^4.12.3",
"node-cron": "^4.2.1",
"nodemailer": "^6.10.1",
"webdav": "^5.9.0"
},
"devDependencies": {
@@ -21,6 +22,7 @@
"@types/better-sqlite3": "^7.6.13",
"@types/node": "^25.3.5",
"@types/node-cron": "^3.0.11",
"@types/nodemailer": "^6.4.17",
"drizzle-kit": "^0.31.9",
"typescript": "^5.9.3",
"wrangler": "^4.71.0"
+11
View File
@@ -53,12 +53,23 @@ CREATE TABLE IF NOT EXISTS auth_passkeys (
last_used_at INTEGER
);
-- Email 备份历史记录表
CREATE TABLE IF NOT EXISTS backup_email_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
provider_id INTEGER NOT NULL,
filename TEXT NOT NULL,
recipient TEXT 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);
CREATE INDEX IF NOT EXISTS idx_backup_email_history_provider_id ON backup_email_history(provider_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_passkeys_user_id ON auth_passkeys(user_id);
-- Remove any existing duplicates before enforcing unique constraint; keep the earliest
+10 -10
View File
@@ -622,11 +622,11 @@ backups.use('*', authMiddleware);
// =========================================================================
backups.get('/providers', async (c) => {
const service = new BackupService(c.env);
const service = new BackupService(c.env, c.req.header('Accept-Language'));
const providers = await service.getProvidersList();
// Check which providers are enabled via environment variables
const availableTypes = ['s3', 'telegram', 'webdav'];
const availableTypes = ['s3', 'telegram', 'webdav', 'email'];
if (c.env.OAUTH_GOOGLE_CLIENT_ID && c.env.OAUTH_GOOGLE_CLIENT_SECRET) {
availableTypes.push('gdrive');
}
@@ -644,14 +644,14 @@ backups.get('/providers', async (c) => {
});
backups.post('/providers', async (c) => {
const service = new BackupService(c.env);
const service = new BackupService(c.env, c.req.header('Accept-Language'));
const data = await c.req.json();
const id = await service.addProvider(data);
return c.json({ success: true, id });
});
backups.put('/providers/:id', async (c) => {
const service = new BackupService(c.env);
const service = new BackupService(c.env, c.req.header('Accept-Language'));
const id = Number(c.req.param('id'));
const data = await c.req.json();
await service.updateProvider(id, data);
@@ -659,21 +659,21 @@ backups.put('/providers/:id', async (c) => {
});
backups.delete('/providers/:id', async (c) => {
const service = new BackupService(c.env);
const service = new BackupService(c.env, c.req.header('Accept-Language'));
const id = Number(c.req.param('id'));
await service.deleteProvider(id);
return c.json({ success: true });
});
backups.post('/providers/test', async (c) => {
const service = new BackupService(c.env);
const service = new BackupService(c.env, c.req.header('Accept-Language'));
const { type, config, id } = await c.req.json();
await service.testConnection(type, config, id);
return c.json({ success: true, message: 'Connection successful' });
});
backups.post('/providers/:id/backup', async (c) => {
const service = new BackupService(c.env);
const service = new BackupService(c.env, c.req.header('Accept-Language'));
const id = Number(c.req.param('id'));
const body = await c.req.json();
await service.executeManualBackup(id, body);
@@ -681,14 +681,14 @@ backups.post('/providers/:id/backup', async (c) => {
});
backups.get('/providers/:id/files', async (c) => {
const service = new BackupService(c.env);
const service = new BackupService(c.env, c.req.header('Accept-Language'));
const id = Number(c.req.param('id'));
const files = await service.getFiles(id);
return c.json({ success: true, files });
});
backups.post('/providers/:id/download', async (c) => {
const service = new BackupService(c.env);
const service = new BackupService(c.env, c.req.header('Accept-Language'));
const id = Number(c.req.param('id'));
const { filename } = await c.req.json();
const content = await service.downloadFile(id, filename);
@@ -696,7 +696,7 @@ backups.post('/providers/:id/download', async (c) => {
});
backups.post('/providers/:id/files/delete', async (c) => {
const service = new BackupService(c.env);
const service = new BackupService(c.env, c.req.header('Accept-Language'));
const id = Number(c.req.param('id'));
const { filename } = await c.req.json();
await service.deleteFile(id, filename);
+19 -3
View File
@@ -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, DropboxProvider } from '@/features/backup/providers';
import { BackupProvider, WebDavProvider, S3Provider, TelegramProvider, GoogleDriveProvider, OneDriveProvider, BaiduNetdiskProvider, DropboxProvider, EmailProvider } from '@/features/backup/providers';
import { decryptField } from '@/shared/db/db';
import { vault as vaultTable, backupProviders } from '@/shared/db/schema';
@@ -11,12 +11,16 @@ export class BackupService {
private env: EnvBindings;
private db: any;
constructor(env: EnvBindings) {
constructor(env: EnvBindings, lang: string = 'en-US') {
this.env = env;
this.db = env.DB;
this.repository = new BackupRepository(env.DB);
// Normalize language (simple check: contains 'zh' -> 'zh-CN', else 'en-US')
this.lang = lang.toLowerCase().includes('zh') ? 'zh-CN' : 'en-US';
}
private lang: string;
private readonly MASK = '******';
private readonly SENSITIVE_FIELDS: Record<string, string[]> = {
webdav: ['password'],
@@ -25,7 +29,8 @@ export class BackupService {
gdrive: ['refreshToken'],
onedrive: ['refreshToken'],
baidu: ['refreshToken'],
dropbox: ['refreshToken']
dropbox: ['refreshToken'],
email: ['smtpPassword']
};
private maskConfigForFrontend(type: string, config: any) {
@@ -89,6 +94,8 @@ export class BackupService {
return new BaiduNetdiskProvider(config, this.env);
case 'dropbox':
return new DropboxProvider(config, this.env);
case 'email':
return new EmailProvider(config, this.db, id, this.lang);
default:
throw new AppError('provider_not_found', 400);
}
@@ -117,6 +124,9 @@ export class BackupService {
if (type === 'dropbox' && processed.refreshToken) {
processed.refreshToken = await encryptData(processed.refreshToken, key);
}
if (type === 'email' && processed.smtpPassword) {
processed.smtpPassword = await encryptData(processed.smtpPassword, key);
}
return JSON.stringify(processed);
}
@@ -143,6 +153,9 @@ export class BackupService {
if (type === 'dropbox' && config.refreshToken) {
config.refreshToken = await decryptData(config.refreshToken, key);
}
if (type === 'email' && config.smtpPassword) {
config.smtpPassword = await decryptData(config.smtpPassword, key);
}
return config;
}
@@ -336,6 +349,9 @@ export class BackupService {
if (e.message === 'oauth_token_revoked' || e.message?.includes('oauth_token_revoked')) {
throw new AppError('oauth_token_revoked', 401);
}
if (e.message === 'email_download_not_supported') {
throw new AppError('email_download_not_supported', 400);
}
throw new AppError(`download_failed: ${e.message}`, 500);
}
}
@@ -0,0 +1,459 @@
import { BackupProvider, BackupFile } from '@/features/backup/providers/backupProvider';
import { backupEmailHistory } from '@/shared/db/schema';
import { eq, desc, and } from 'drizzle-orm';
// ============================================================
// 纯手写 SMTP 客户端 (兼容 Cloudflare Workers TCP + Node.js网络)
// 遵循 RFC 5321/5322,支持 STARTTLS (587) 和 SSL/TLS (465)
// ============================================================
/**
* 将任意字符串编码为 UTF-8 安全的 Base64(兼容 Workers 和 Node.js)
* 使用 TextEncoder 确保正确处理中文、emoji 等多字节字符,
* 避免直接使用 btoa() 因 Latin1 限制而报错。
*/
function encodeBase64Utf8(str: string): string {
const bytes = new TextEncoder().encode(str);
let binary = '';
bytes.forEach(b => binary += String.fromCharCode(b));
return btoa(binary);
}
/**
* 构建符合 RFC 5322 规范的 MIME 邮件原文(带 base64 附件)
*/
function buildMimeEmail(params: {
from: string;
to: string;
subject: string;
body: string;
filename: string;
attachmentData: string; // base64 encoded
}): string {
const boundary = `----=_Part_${Date.now()}_${Math.random().toString(36).slice(2)}`;
const encodedSubject = `=?UTF-8?B?${encodeBase64Utf8(params.subject)}?=`;
// Split base64 into 76-char lines (RFC 2045)
const b64Lines = params.attachmentData.match(/.{1,76}/g)?.join('\r\n') || params.attachmentData;
return [
`From: ${params.from}`,
`To: ${params.to}`,
`Subject: ${encodedSubject}`,
`MIME-Version: 1.0`,
`Content-Type: multipart/mixed; boundary="${boundary}"`,
``,
`--${boundary}`,
`Content-Type: text/plain; charset=UTF-8`,
`Content-Transfer-Encoding: base64`,
``,
encodeBase64Utf8(params.body),
``,
`--${boundary}`,
`Content-Type: application/json; name="${params.filename}"`,
`Content-Transfer-Encoding: base64`,
`Content-Disposition: attachment; filename="${params.filename}"`,
``,
b64Lines,
``,
`--${boundary}--`,
].join('\r\n');
}
// ============================================================
// 发信核心:通过 fetch + SMTP Relay (Mailchannels-compatible)
// 或直接使用 SMTP over TCP (via Cloudflare connect() or Node net)
//
// 架构决策:使用 nodemailer API-compatible 抽象层
// - Docker (Node.js) => 动态 require('nodemailer')
// - Workers => HTTP-based relay / TCP connect()
// ============================================================
type SmtpConfig = {
host: string;
port: number;
secure: boolean; // true = SSL/TLS (465), false = STARTTLS (587)
user: string;
password: string;
from: string;
to: string;
};
const EMAIL_TRANSLATIONS: Record<string, any> = {
'zh-CN': {
subject: '📦 2FAuth Worker 定期备份 {time}',
intro: '您好,\n\n这是您的 2FAuth Worker 自动定期备份文件,请妥善保管。',
filename: '文件名:{filename}',
time: '备份时间:{time}',
note: '注意:附件内容经过 AES-256 加密,请使用您设置的备份密码进行解密恢复。',
footer: '---\n2FAuth Worker 自动备份系统',
testSubject: '🔗 2FAuth Worker 备份源连接测试',
testBody: '✅ 您的 2FAuth Worker 邮件备份源已成功连接!'
},
'en-US': {
subject: '📦 2FAuth Worker Scheduled Backup {time}',
intro: 'Hello,\n\nThis is your 2FAuth Worker automatic scheduled backup file. Please keep it safe.',
filename: 'Filename: {filename}',
time: 'Backup Time: {time}',
note: 'Note: The attachment is encrypted with AES-256. Please use your backup password to decrypt and restore it.',
footer: '---\n2FAuth Worker Automatic Backup System',
testSubject: '🔗 2FAuth Worker Backup Connection Test',
testBody: '✅ Your 2FAuth Worker email backup connection is working correctly!'
}
};
/**
* 通用发邮件函数。
* 在 Node.js (Docker) 环境下,动态加载 nodemailer。
* 在 Cloudflare Workers 环境下,使用 MailChannels HTTP relay 或原生 TCP。
*/
async function sendEmail(smtp: SmtpConfig, subject: string, body: string, filename: string, attachmentData: string): Promise<void> {
// 检测运行环境:
// - 真实 Node.js (Docker): process.versions.node 存在,且 new Function() 不受 CSP 限制
// - Cloudflare Workers 运行时 / wrangler dev (miniflare): V8 isolate 会阻止 new Function()
// 即使 process 对象存在(miniflare 在 Node.js 中运行),CSP 仍然生效
// 用 "能否执行 new Function" 来区分两者,比单纯检查 process.versions.node 更可靠
let isRealNode = false;
try {
new Function('return true')();
isRealNode = typeof process !== 'undefined' && !!process.versions?.node;
} catch (_) {
isRealNode = false; // 被 CSP 阻止 → 在 Workers/miniflare 沙箱中
}
if (isRealNode) {
// ---- Docker / Node.js 路径:使用 nodemailer ----
// 使用 new Function() 包裹 import,防止 esbuild (wrangler) 在编译期静态解析 nodemailer。
// 该代码块只在 Node.js 运行时执行(isNode 为 true),Workers 环境永远不会触达此处。
// eslint-disable-next-line no-new-func
const nodemailer = await new Function('m', 'return import(m)')('nodemailer');
const transporter = nodemailer.createTransport({
host: smtp.host,
port: smtp.port,
secure: smtp.secure,
auth: {
user: smtp.user,
pass: smtp.password,
},
});
await transporter.sendMail({
from: smtp.from || smtp.user,
to: smtp.to,
subject: subject,
text: body,
attachments: [
{
filename: filename,
content: attachmentData,
encoding: 'base64',
contentType: 'application/json',
},
],
});
} else {
// ---- Cloudflare Workers 路径:使用 cloudflare:sockets 直连 SMTP ----
// 部分邮件服务(如 QQ Mail)的 SMTP 服务器群有负载均衡,不同实例对
// Cloudflare 出口 IP 的策略不同。最多重试 3 次(每次新 TCP 连接可能
// 命中不同的服务器实例),遇到明确的认证失败则立即停止(不再重试)。
const MAX_ATTEMPTS = 3;
let lastError: Error = new Error('email_send_failed: unknown');
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
try {
await sendViaSmtpTcp(smtp, subject, body, filename, attachmentData);
return; // 发送成功,退出
} catch (e: any) {
lastError = e;
// 认证错误(5xx auth 类)是终止性错误,无需重试
const isAuthError = e.message?.includes('auth_success_failed') ||
e.message?.includes('535') || e.message?.includes('534') ||
e.message?.includes('Username and Password not accepted');
if (isAuthError || attempt === MAX_ATTEMPTS) break;
// 短暂等待后重试(利用 DNS 轮询命中不同服务器实例)
await new Promise(resolve => setTimeout(resolve, 600));
}
}
throw lastError;
}
}
/**
* Cloudflare Workers 路径:使用 cloudflare:sockets 原生 TCP 直连 SMTP 服务器
*
* 支持:
* - SSL/TLS (port 465):连接时即刻加密(secureTransport: 'on')
* - STARTTLS (port 587):先明文连接,发 STARTTLS 后升级到 TLS(secureTransport: 'starttls')
*
* 协议:RFC 5321,使用 AUTH LOGIN 认证机制
*/
async function sendViaSmtpTcp(smtp: SmtpConfig, subject: string, body: string, filename: string, attachmentData: string): Promise<void> {
// cloudflare:sockets 是 Cloudflare Workers 内置外部模块,
// wrangler/esbuild 会自动标记为 external,不用 new Function 技巧。
// Node.js (Docker) 路径由 isNode 幸下防护,永远不会进入此函数。
// @ts-ignore - cloudflare:sockets 是 Workers 运行时内置模块
const { connect } = await import('cloudflare:sockets');
const rawEmail = buildMimeEmail({
from: smtp.from || smtp.user,
to: smtp.to,
subject,
body,
filename,
attachmentData,
});
const isSSL = smtp.secure; // true = port 465 (SSL), false = port 587 (STARTTLS)
// 建立 TCP 连接
let sock = connect(`${smtp.host}:${smtp.port}`, {
secureTransport: isSSL ? 'on' : 'starttls',
allowHalfOpen: false,
});
const dec = new TextDecoder();
const enc = new TextEncoder();
let buf = '';
let reader = sock.readable.getReader();
let writer = sock.writable.getWriter();
/**
* 读取一条完整 SMTP 响应(自动处理多行,如 EHLO 能力列表)
* SMTP 多行格式:250-XXX(连续行)/ 250 XXX(最后一行,第4字符为空格)
*/
const readResp = async (): Promise<string> => {
let last = '';
for (; ;) {
const nl = buf.indexOf('\n');
if (nl !== -1) {
const line = buf.slice(0, nl).replace(/\r$/, '');
buf = buf.slice(nl + 1);
last = line;
// 第4个字符是 '-' 说明还有后续行;空格或不足4字符说明是最后一行
if (line.length < 4 || line[3] !== '-') return line;
} else {
try {
const { value, done } = await reader.read();
if (done) {
// 服务器关闭连接,返回已读到的最后一行(如果有)
if (last) return last;
throw new Error('smtp_connection_closed: Server closed the connection unexpectedly');
}
buf += dec.decode(value);
} catch (e: any) {
// 流被取消(如服务器拒绝 IP、TLS 握手失败、连接重置等)
if (e.message?.includes('smtp_')) throw e; // 重新抛出已格式化的错误
throw new Error(
`smtp_connection_closed: ${e.message}. ` +
`Possible causes: server rejected your IP (common with Chinese providers from Cloudflare), ` +
`wrong credentials, or TLS handshake failure.`
);
}
}
}
};
const send = (s: string) => writer.write(enc.encode(s + '\r\n'));
/**
* 发送命令并验证响应码,不匹配则抛出含服务器应答的错误
*/
const expect = async (code: string, label: string): Promise<string> => {
const resp = await readResp();
if (!resp.startsWith(code)) throw new Error(`smtp_${label}_failed: ${resp.slice(0, 120)}`);
return resp;
};
try {
// 1. 服务器问候
await expect('220', 'greeting');
// 2. EHLO — 声明客户端身份并获取服务器能力
await send('EHLO 2fauth-worker');
await expect('250', 'ehlo');
// 3. STARTTLS 升级(仅 587 端口需要)
if (!isSSL) {
await send('STARTTLS');
await expect('220', 'starttls_ready');
// ⚠️ 关键:必须用同步的 releaseLock() 释放锁,再调用 startTls()
// reader.cancel() 是异步的,锁未立即释放,会导致 startTls() 后
// tlsSock.readable.getReader() 抛出 "ReadableStream has been locked" 错误
reader.releaseLock();
writer.releaseLock();
const tlsSock = sock.startTls();
buf = '';
reader = tlsSock.readable.getReader();
writer = tlsSock.writable.getWriter();
// TLS 握手完成后需再次发送 EHLO
await send('EHLO 2fauth-worker');
await expect('250', 'ehlo_after_tls');
}
// 4. AUTH LOGIN 认证
await send('AUTH LOGIN');
await expect('334', 'auth_prompt_user');
// 用户名和密码分别 Base64 编码(使用 UTF-8 安全编码确保兼容性)
await send(encodeBase64Utf8(smtp.user));
await expect('334', 'auth_prompt_pass');
await send(encodeBase64Utf8(smtp.password));
await expect('235', 'auth_success');
// 5. 发件人和收件人信封
await send(`MAIL FROM:<${smtp.from || smtp.user}>`);
await expect('250', 'mail_from');
await send(`RCPT TO:<${smtp.to}>`);
await expect('250', 'rcpt_to');
// 6. 邮件正文传输
await send('DATA');
await expect('354', 'data_ready');
// RFC 2821 dot-stuffing:以 '.' 开头的行需要再加一个 '.',防止误触 DATA 结束标记
const stuffed = rawEmail
.split('\r\n')
.map(line => line.startsWith('.') ? '.' + line : line)
.join('\r\n');
// 邮件内容后跟 <CRLF>.<CRLF> 表示 DATA 结束
await writer.write(enc.encode(stuffed + '\r\n.\r\n'));
await expect('250', 'message_accepted');
// 7. 正常退出
await send('QUIT');
// QUIT 响应为 221,不必强制检查(部分服务器直接关闭连接)
await readResp().catch(() => { });
} finally {
writer.close().catch(() => { });
}
}
// ============================================================
// EmailProvider 类实现
// ============================================================
export class EmailProvider implements BackupProvider {
private smtp: SmtpConfig;
private db: any;
private providerId: number | undefined;
private lang: string;
constructor(config: any, db?: any, providerId?: number, lang: string = 'en-US') {
if (!config.smtpHost || !config.smtpUser || !config.smtpPassword || !config.smtpTo) {
throw new Error('email_config_incomplete');
}
this.smtp = {
host: config.smtpHost,
port: parseInt(config.smtpPort || '587', 10),
secure: config.smtpSecure === true || config.smtpSecure === 'true',
user: config.smtpUser,
password: config.smtpPassword,
from: config.smtpFrom || config.smtpUser,
to: config.smtpTo,
};
this.db = db;
this.providerId = providerId;
this.lang = lang;
}
async testConnection(): Promise<boolean> {
// 发送一封测试邮件来验证配置
const t = EMAIL_TRANSLATIONS[this.lang] || EMAIL_TRANSLATIONS['en-US'];
const subject = t.testSubject;
const body = t.testBody;
const filename = '2fa-test-email.txt';
try {
await sendEmail(this.smtp, subject, body, filename, encodeBase64Utf8(body));
return true;
} catch (e: any) {
throw new Error(`email_send_failed: ${e.message}`);
}
}
async listBackups(): Promise<BackupFile[]> {
if (!this.db || !this.providerId) {
throw new Error('email_db_missing');
}
const histories = await this.db.select()
.from(backupEmailHistory)
.where(eq(backupEmailHistory.providerId, this.providerId))
.orderBy(desc(backupEmailHistory.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('email_db_missing');
}
const ts = new Date().toISOString();
const t = EMAIL_TRANSLATIONS[this.lang] || EMAIL_TRANSLATIONS['en-US'];
const subject = t.subject.replace('{time}', ts);
const body = [
t.intro,
'',
t.filename.replace('{filename}', filename),
t.time.replace('{time}', ts),
'',
t.note,
'',
t.footer
].join('\n');
const attachmentData = encodeBase64Utf8(data);
await sendEmail(this.smtp, subject, body, filename, attachmentData);
// 记录到本地历史
await this.db.insert(backupEmailHistory).values({
providerId: this.providerId,
filename: filename,
recipient: this.smtp.to,
size: data.length,
createdAt: Date.now(),
}).run();
}
async downloadBackup(filename: string): Promise<string> {
// Email 是"推"模式,不支持从邮箱下载。
// 抛出明确的错误告知用户需要从邮箱手动下载附件并上传恢复。
throw new Error('email_download_not_supported');
}
async deleteBackup(filename: string): Promise<void> {
if (!this.db || !this.providerId) {
throw new Error('email_db_missing');
}
// 仅删除本地历史记录(无法撤回已发送的邮件)
const record = await this.db.select()
.from(backupEmailHistory)
.where(and(
eq(backupEmailHistory.providerId, this.providerId),
eq(backupEmailHistory.filename, filename)
))
.get();
if (record) {
await this.db.delete(backupEmailHistory)
.where(eq(backupEmailHistory.id, record.id))
.run();
}
}
}
@@ -5,4 +5,5 @@ export * from '@/features/backup/providers/telegramProvider';
export * from '@/features/backup/providers/googleDriveProvider';
export * from '@/features/backup/providers/oneDriveProvider';
export * from '@/features/backup/providers/baiduNetdiskProvider';
export * from '@/features/backup/providers/dropboxProvider';
export * from '@/features/backup/providers/dropboxProvider';
export * from '@/features/backup/providers/emailProvider';
@@ -33,7 +33,7 @@ export class TelegramProvider implements BackupProvider {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
chat_id: this.chatId,
text: '🔗 2FAuth Backup Connected / 备份源连接成功',
text: '🔗 2FAuth Worker Backup Connected / 备份源连接成功',
disable_notification: true
})
});
+13 -1
View File
@@ -45,6 +45,16 @@ export const backupTelegramHistory = sqliteTable('backup_telegram_history', {
createdAt: integer('created_at').notNull(),
});
// 5. Email 备份历史记录表
export const backupEmailHistory = sqliteTable('backup_email_history', {
id: integer('id').primaryKey({ autoIncrement: true }),
providerId: integer('provider_id').notNull(),
filename: text('filename').notNull(),
recipient: text('recipient').notNull(), // 收件人邮箱地址
size: integer('size').notNull(),
createdAt: integer('created_at').notNull(),
});
// 4. 通行密钥表 (Passkeys)
export const authPasskeys = sqliteTable('auth_passkeys', {
credentialId: text('credential_id').primaryKey(), // 唯一凭据 ID
@@ -62,4 +72,6 @@ export type NewVaultItem = typeof vault.$inferInsert;
export type BackupProvider = typeof backupProviders.$inferSelect;
export type NewBackupProvider = typeof backupProviders.$inferInsert;
export type BackupTelegramHistory = typeof backupTelegramHistory.$inferSelect;
export type NewBackupTelegramHistory = typeof backupTelegramHistory.$inferInsert;
export type NewBackupTelegramHistory = typeof backupTelegramHistory.$inferInsert;
export type BackupEmailHistory = typeof backupEmailHistory.$inferSelect;
export type NewBackupEmailHistory = typeof backupEmailHistory.$inferInsert;
@@ -73,14 +73,15 @@
<el-dialog v-model="showConfigDialog" :title="isEditing ? $t('backup.edit_source') : $t('backup.add_source')" :width="layoutStore.isMobile ? '90%' : '500px'" destroy-on-close>
<el-form :model="form" label-position="top" ref="formRef">
<el-form-item :label="$t('backup.type_label')">
<el-select v-model="form.type" :disabled="isEditing">
<el-select v-model="form.type" :placeholder="$t('backup.select_type')" class="w-full" :disabled="isEditing">
<el-option v-if="availableTypes.includes('email')" :label="$t('backup.type_email')" value="email" />
<el-option v-if="availableTypes.includes('s3')" :label="$t('backup.type_s3')" value="s3" />
<el-option v-if="availableTypes.includes('telegram')" :label="$t('backup.type_telegram')" value="telegram" />
<el-option v-if="availableTypes.includes('webdav')" :label="$t('backup.type_webdav')" value="webdav" />
<el-option v-if="availableTypes.includes('gdrive')" :label="$t('backup.type_gdrive')" value="gdrive" />
<el-option v-if="availableTypes.includes('onedrive')" :label="$t('backup.type_onedrive')" value="onedrive" />
<el-option v-if="availableTypes.includes('baidu')" :label="$t('backup.type_baidu')" value="baidu" />
<el-option v-if="availableTypes.includes('dropbox')" :label="$t('backup.type_dropbox')" value="dropbox" />
<el-option v-if="availableTypes.includes('baidu')" :label="$t('backup.type_baidu')" value="baidu" />
</el-select>
</el-form-item>
<el-form-item :label="$t('backup.name_label')">
@@ -384,6 +385,61 @@
</div>
</template>
<!-- Email (SMTP) 配置 -->
<template v-if="form.type === 'email'">
<el-alert type="info" :description="$t('backup.email_tip')" :closable="false" class="mb-15" show-icon />
<!-- SMTP 服务器 + 端口 -->
<el-form-item :label="$t('backup.email_smtp_host')">
<div class="flex gap-10" style="width:100%">
<el-input
v-model="form.config.smtpHost"
:placeholder="$t('backup.email_smtp_host_placeholder')"
style="flex:1"
/>
<el-input-number
v-model="form.config.smtpPort"
:min="1"
:max="65535"
style="width: 110px"
controls-position="right"
/>
</div>
</el-form-item>
<!-- 加密方式 -->
<el-form-item :label="$t('backup.email_smtp_secure')">
<el-radio-group v-model="form.config.smtpSecure" @change="handleSmtpSecureChange">
<el-radio :value="false">{{ $t('backup.email_smtp_secure_tls') }}</el-radio>
<el-radio :value="true">{{ $t('backup.email_smtp_secure_ssl') }}</el-radio>
</el-radio-group>
</el-form-item>
<!-- 发件人账号 -->
<el-form-item :label="$t('backup.email_smtp_user')">
<el-input v-model="form.config.smtpUser" :placeholder="$t('backup.email_smtp_user_placeholder')" />
</el-form-item>
<!-- 授权码 / 密码 -->
<el-form-item :label="$t('backup.email_smtp_password')">
<div v-if="isEditing && !isEditingEmailPwd" class="flex flex-items-center flex-between bg-fill p-10 rounded-4 border-1 w-full h-32">
<span class="font-mono ls-2">******</span>
<el-button link type="primary" @click="isEditingEmailPwd = true; form.config.smtpPassword = ''">{{ $t('backup.modify') }}</el-button>
</div>
<el-input v-else v-model="form.config.smtpPassword" type="password" show-password />
</el-form-item>
<!-- 发件人昵称(可选) -->
<el-form-item :label="$t('backup.email_smtp_from')">
<el-input v-model="form.config.smtpFrom" :placeholder="$t('backup.email_smtp_from_placeholder')" />
</el-form-item>
<!-- 收件人邮箱 -->
<el-form-item :label="$t('backup.email_smtp_to')">
<el-input v-model="form.config.smtpTo" :placeholder="$t('backup.email_smtp_to_placeholder')" />
</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')" />
@@ -441,6 +497,15 @@
<!-- 恢复列表弹窗 -->
<el-dialog v-model="showRestoreListDialog" :title="$t('backup.select_restore_file')" :width="layoutStore.isMobile ? '95%' : '600px'">
<!-- Email 备份专用提示:该类型无法在线下载,引导用户从邮箱手动导入 -->
<el-alert
v-if="currentActionProvider?.type === 'email'"
:title="$t('backup.email_download_not_supported')"
type="warning"
show-icon
:closable="false"
class="mb-15"
/>
<el-table :data="backupFiles" v-loading="isLoadingFiles" height="300px" style="width: 100%">
<el-table-column prop="filename" :label="$t('backup.filename')" show-overflow-tooltip />
<el-table-column :label="$t('backup.size')" width="100">
@@ -485,10 +550,10 @@ const layoutStore = useLayoutStore()
const {
providers, isLoading, showConfigDialog, isEditing, isTesting, isSaving,
isEditingWebdavPwd, isEditingS3Secret, isEditingTelegramToken, form,
isEditingWebdavPwd, isEditingS3Secret, isEditingTelegramToken, isEditingEmailPwd, form,
isAutoBackupPasswordSaved, shouldUseExistingAutoBackupPassword, fetchProviders, openAddDialog,
editProvider, testConnection, saveProvider, deleteProvider,
startGoogleAuth, startMicrosoftAuth, startBaiduAuth, startDropboxAuth, handleAuthMessage,
startGoogleAuth, startMicrosoftAuth, startBaiduAuth, startDropboxAuth, handleAuthMessage,
isAuthenticatingGoogle, isAuthenticatingMicrosoft, isAuthenticatingBaidu, isAuthenticatingDropbox,
authStatusGoogle, authStatusMicrosoft, authStatusBaidu, authStatusDropbox,
authErrorMessageGoogle, authErrorMessageMicrosoft, authErrorMessageBaidu, authErrorMessageDropbox,
@@ -515,6 +580,20 @@ onUnmounted(() => {
if (cleanupAuthListener) cleanupAuthListener()
})
const handleSmtpSecureChange = (val) => {
if (val) {
// SSL/TLS -> 465
if (!form.value.config.smtpPort || form.value.config.smtpPort === 587) {
form.value.config.smtpPort = 465
}
} else {
// STARTTLS -> 587
if (!form.value.config.smtpPort || form.value.config.smtpPort === 465) {
form.value.config.smtpPort = 587
}
}
}
const getProviderTypeTag = (type) => {
const map = {
@@ -524,7 +603,8 @@ const getProviderTypeTag = (type) => {
gdrive: 'danger',
onedrive: 'success',
baidu: 'warning',
dropbox: 'primary'
dropbox: 'primary',
email: 'info'
}
return map[type] || 'info'
}
@@ -23,6 +23,7 @@ export function useBackupProviders() {
const isEditingWebdavPwd = ref(false)
const isEditingS3Secret = ref(false)
const isEditingTelegramToken = ref(false)
const isEditingEmailPwd = ref(false)
const isAuthenticatingGoogle = ref(false)
const isAuthenticatingMicrosoft = ref(false)
const isAuthenticatingBaidu = ref(false)
@@ -37,9 +38,20 @@ export function useBackupProviders() {
const authErrorMessageDropbox = ref('')
const initialFormState = () => ({
type: 's3',
type: 'email',
name: '',
config: { url: '', username: '', password: '', saveDir: '/2fauth-worker-backup', endpoint: '', bucket: '', region: 'auto', accessKeyId: '', secretAccessKey: '', botToken: '', chatId: '', refreshToken: '', folderId: '' },
config: {
// WebDAV
url: '', username: '', password: '', saveDir: '/2fauth-worker-backup',
// S3
endpoint: '', bucket: '', region: 'auto', accessKeyId: '', secretAccessKey: '',
// Telegram
botToken: '', chatId: '',
// OAuth (gdrive/onedrive/baidu/dropbox)
refreshToken: '', folderId: '',
// Email
smtpHost: '', smtpPort: '587', smtpSecure: false, smtpUser: '', smtpPassword: '', smtpFrom: '', smtpTo: ''
},
autoBackup: false,
autoBackupPassword: '',
autoBackupRetain: 30
@@ -76,6 +88,7 @@ export function useBackupProviders() {
isEditingWebdavPwd.value = false
isEditingS3Secret.value = false
isEditingTelegramToken.value = false
isEditingEmailPwd.value = false
form.value = initialFormState()
isAutoBackupPasswordSaved.value = false
shouldUseExistingAutoBackupPassword.value = false
@@ -87,6 +100,7 @@ export function useBackupProviders() {
isEditingWebdavPwd.value = false
isEditingS3Secret.value = false
isEditingTelegramToken.value = false
isEditingEmailPwd.value = false
currentProviderId.value = provider.id
form.value = JSON.parse(JSON.stringify({
type: provider.type,
@@ -124,6 +138,11 @@ export function useBackupProviders() {
if (!config.refreshToken) return t('backup.require_baidu_auth')
} else if (form.value.type === 'dropbox') {
if (!config.refreshToken) return t('backup.require_dropbox_auth')
} else if (form.value.type === 'email') {
if (!config.smtpHost) return t('backup.require_email_smtp_host')
if (!config.smtpUser) return t('backup.require_email_smtp_user')
if (!config.smtpPassword) return t('backup.require_email_smtp_password')
if (!config.smtpTo) return t('backup.require_email_smtp_to')
}
if (form.value.autoBackup) {
@@ -459,6 +478,7 @@ export function useBackupProviders() {
isEditingWebdavPwd,
isEditingS3Secret,
isEditingTelegramToken,
isEditingEmailPwd,
isAuthenticatingGoogle,
isAuthenticatingMicrosoft,
isAuthenticatingBaidu,
+31
View File
@@ -136,6 +136,32 @@
"type_onedrive": "OneDrive",
"type_baidu": "Baidu Netdisk",
"type_dropbox": "Dropbox",
"type_email": "Email (SMTP)",
"email_smtp_host": "SMTP Server",
"email_smtp_port": "Port",
"email_smtp_host_placeholder": "e.g., smtp.gmail.com",
"email_smtp_secure": "Encryption",
"email_smtp_secure_ssl": "SSL / TLS (Port 465)",
"email_smtp_secure_tls": "STARTTLS (Port 587)",
"email_smtp_user": "Sender Account",
"email_smtp_user_placeholder": "Sender email address",
"email_smtp_password": "Auth Code / Password",
"email_smtp_from": "Sender Name (Optional)",
"email_smtp_from_placeholder": "e.g., 2FAuth Worker Backup",
"email_smtp_to": "Recipient Email",
"email_smtp_to_placeholder": "Email address to receive the backup",
"email_tip": "Backup files will be sent as encrypted attachments to the specified email. Backup history is only stored locally.",
"email_download_not_supported": "Email backups cannot be downloaded directly. Please open your email, save the attachment manually, and then use the \"Migration\" feature to import.",
"require_email_smtp_host": "Please enter SMTP server address",
"require_email_smtp_user": "Please enter the sender account",
"require_email_smtp_password": "Please enter auth code or password",
"require_email_smtp_to": "Please enter recipient email address",
"email_backup_subject": "📦 2FAuth Worker Scheduled Backup {time}",
"email_backup_body_intro": "Hello,\n\nThis is your 2FAuth Worker automatic scheduled backup file. Please keep it safe.",
"email_backup_body_filename": "Filename: {filename}",
"email_backup_body_time": "Backup Time: {time}",
"email_backup_body_note": "Note: The attachment is encrypted with AES-256. Please use your backup password to decrypt and restore it.",
"email_backup_body_footer": "---\n2FAuth Worker Automatic Backup System",
"name_label": "Name (Alias)",
"name_placeholder": "e.g., OpenList",
"webdav_url": "WebDAV URL",
@@ -615,6 +641,11 @@
"webauthn_registration_failed": "Passkey registration failed",
"webauthn_login_failed": "Passkey login failed",
"passkey_not_found": "No matching Passkey credential found",
"email_config_incomplete": "Email backup config incomplete (missing SMTP host, account, or recipient)",
"email_db_missing": "Database connection or Provider ID is missing",
"email_send_failed": "Failed to send email",
"email_send_failed_via_relay": "Failed to send email via relay",
"email_download_not_supported": "Email backup does not support direct download. Please retrieve the file from your email's attachment.",
"health_check_failed": "Security vulnerability detected! System access is locked."
},
"healthCheck": {
+31
View File
@@ -136,6 +136,32 @@
"type_onedrive": "OneDrive",
"type_baidu": "百度网盘",
"type_dropbox": "Dropbox",
"type_email": "电子邮箱 (SMTP)",
"email_smtp_host": "SMTP 服务器",
"email_smtp_port": "端口",
"email_smtp_host_placeholder": "例如: smtp.gmail.com",
"email_smtp_secure": "加密方式",
"email_smtp_secure_ssl": "SSL / TLS (端口 465)",
"email_smtp_secure_tls": "STARTTLS (端口 587)",
"email_smtp_user": "发件人账号",
"email_smtp_user_placeholder": "发件人邮箱地址",
"email_smtp_password": "授权码 / 密码",
"email_smtp_from": "发件人昵称 (可选)",
"email_smtp_from_placeholder": "例如: 2FAuth Worker Backup",
"email_smtp_to": "收件人邮箱",
"email_smtp_to_placeholder": "接收备份文件的邮箱地址",
"email_tip": "备份文件将以加密附件形式发送到指定邮箱,备份历史仅保存在本地记录中。",
"email_download_not_supported": "邮箱备份无法直接下载,请打开邮箱从附件中手动保存备份文件,然后使用\"数据迁移\"功能导入。",
"require_email_smtp_host": "请输入 SMTP 服务器地址",
"require_email_smtp_user": "请输入发件人账号",
"require_email_smtp_password": "请输入授权码或密码",
"require_email_smtp_to": "请输入收件人邮箱地址",
"email_backup_subject": "📦 2FAuth Worker 定期备份 {time}",
"email_backup_body_intro": "您好,\n\n这是您的 2FAuth Worker 自动定期备份文件,请妥善保管。",
"email_backup_body_filename": "文件名:{filename}",
"email_backup_body_time": "备份时间:{time}",
"email_backup_body_note": "注意:附件内容经过 AES-256 加密,请使用您设置的备份密码进行解密恢复。",
"email_backup_body_footer": "---\n2FAuth Worker 自动备份系统",
"name_label": "名称 (别名)",
"name_placeholder": "例如: OpenList",
"webdav_url": "WebDAV 地址",
@@ -615,6 +641,11 @@
"webauthn_registration_failed": "通行密钥注册失败",
"webauthn_login_failed": "通行密钥登录失败",
"passkey_not_found": "未找到对应的通行密钥凭证",
"email_config_incomplete": "邮箱备份配置不完整 (缺少 SMTP 服务器、账号或收件人)",
"email_db_missing": "缺少数据库连接或 Provider ID",
"email_send_failed": "邮件发送失败",
"email_send_failed_via_relay": "通过中继发送邮件失败",
"email_download_not_supported": "邮件备份不支持直接下载,请从邮箱附件中手动获取备份文件",
"health_check_failed": "环境配置漏洞警告,系统已被锁定!"
},
"healthCheck": {