fix: 完善导入csv格式数据处理逻辑

This commit is contained in:
nap0o committed 2026-03-03 03:08:01 +08:00
1 parent 9404e34bd3
commit 5ab24ee862
7 files changed
+107 -37

No files matched your search

+6
View File
@@ -117,6 +117,12 @@ jobs:
echo "$SECRETS" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
- name: Mask Sensitive Domain
run: |
if [ -n "${{ secrets.CLOUDFLARE_WORKER_SUBDOMAIN }}" ]; then
echo "::add-mask::${{ secrets.CLOUDFLARE_WORKER_SUBDOMAIN }}"
fi
# 部署到 Cloudflare Workers
- name: Deploy to Cloudflare Workers
uses: cloudflare/wrangler-action@v3
+2 -1
View File
@@ -63,10 +63,11 @@ export async function batchInsertVaultItems(
// 2. 批量写入:使用 D1 的 batch 功能避免连接超时。
// 分批写入,每批最多 50 条。
// 使用 onConflictDoNothing() 作为兜底,遇到重复 (service, account) 时静默跳过而非抛出 500
const CHUNK_SIZE = 50;
for (let i = 0; i < preparedItems.length; i += CHUNK_SIZE) {
const chunk = preparedItems.slice(i, i + CHUNK_SIZE);
const stmts = chunk.map(item => dbClient.insert(schema.vault).values(item));
const stmts = chunk.map(item => dbClient.insert(schema.vault).values(item).onConflictDoNothing());
await dbClient.batch(stmts as any);
}
+14 -11
View File
@@ -1,13 +1,16 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/logo.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>2FAuth - Serverless 2FA Manager.</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/logo.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>2FAuth Worker - A Secure 2FA Management Tool.</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
@@ -45,18 +45,28 @@ export const dataMigrationService = {
return 'phonefactor'
}
let textContent = content
if (content instanceof ArrayBuffer || content instanceof Uint8Array) {
try {
const bytes = content instanceof Uint8Array ? content : new Uint8Array(content)
textContent = new TextDecoder('utf-8', { fatal: false }).decode(bytes)
} catch (e) {
textContent = ''
}
}
if (filename && filename.toLowerCase().endsWith('.csv')) {
const firstLine = content.split('\n')[0].toLowerCase()
const firstLine = typeof textContent === 'string' ? textContent.split('\n')[0].toLowerCase() : ''
if (firstLine.includes('login_totp')) return 'bwauth_csv'
return 'generic_csv'
}
if (typeof content === 'string' && content.trim().startsWith('otpauth://')) {
if (typeof textContent === 'string' && textContent.trim().startsWith('otpauth://')) {
return 'text'
}
if (typeof content === 'string') {
const json = tryParseJSON(content)
if (typeof textContent === 'string') {
const json = tryParseJSON(textContent)
if (json) {
if (Array.isArray(json.items) && json.items.length > 0 && json.items[0].login && json.items[0].login.totp) return 'bwauth_json'
if (json.encrypted === true && json.app === '2fauth') return 'encrypted'
@@ -446,7 +456,7 @@ export const dataMigrationService = {
SQL = await initSqlJs()
}
const db = new SQL.Database(new Uint8Array(dbBuffer))
// 检查accounts表是否存在
const tables = db.exec("SELECT name FROM sqlite_master WHERE type='table' AND name='accounts'")
if (tables.length === 0) {
@@ -549,6 +559,19 @@ export const dataMigrationService = {
return await this.parsePhoneFactor(content)
}
// 统一处理将可能传入的 ArrayBuffer 转回 string,以便后续非数据库格式的解析
let textContent = content
if (content instanceof ArrayBuffer || content instanceof Uint8Array) {
try {
const bytes = content instanceof Uint8Array ? content : new Uint8Array(content)
textContent = new TextDecoder('utf-8', { fatal: false }).decode(bytes)
} catch (e) {
console.warn('Failed to decode buffer as text', e)
}
}
// 对于非 phonefactor 的格式,后续逻辑全部使用 textContent 替代 content
content = textContent;
if (type === 'bwauth_csv' || type === 'generic_csv') {
rawVault = csvStrategy.parseCsv(content)
content = JSON.stringify(rawVault)
@@ -20,17 +20,12 @@
>
<el-icon class="el-icon--upload"><upload-filled /></el-icon>
<div class="el-upload__text">
将备份文件或迁移二维码截图拖到此处,或 <em>点击上传</em>
<p><el-tag type="success" effect="light">💡 自动识别文件类型导入</el-tag></p>
<p>将备份文件或迁移二维码截图拖到此处,或 <em>点击上传</em></p>
</div>
<template #tip>
<div class="import-tips">
<el-alert
title="💡 自动识别文件类型进行导入"
type="success"
:closable="false"
style="margin-bottom: 20px;"
/>
<h4>支持情况说明</h4>
<div class="format-groups">
<div class="format-group">
<h4>📁 本系统备份</h4>
@@ -55,14 +50,16 @@
</div>
<div class="ms-tip">
<span>Microsoft Authenticator <small>(仅支持 Android)</small></span>
<p>将 PhoneFactor 文件直接上传,Android 手机该文件路径为: <code>/data/data/com.azure.authenticator/databases/PhoneFactor</code></p>
<p>Android 手机导出文件 <code>/data/data/com.azure.authenticator/databases/PhoneFactor</code> 后,直接上传</p>
</div>
</div>
<div class="format-group">
<h4>📄 通用格式</h4>
<div class="tags">
<el-tag type="info" effect="light"><el-icon><Document /></el-icon> OTPAuth URI (.txt)</el-tag>
<el-tag type="info" effect="light"><el-icon><Document /></el-icon> 通用格式 (.json)</el-tag>
<el-tag type="info" effect="light"><el-icon><Tickets /></el-icon> OTPAuth URI (.txt)</el-tag>
<el-tag type="info" effect="light"><el-icon><Grid /></el-icon> 电子表格 (.csv)</el-tag>
</div>
</div>
</div>
@@ -115,7 +112,7 @@
</template>
<script setup>
import { UploadFilled, Lock, Unlock, Document } from '@element-plus/icons-vue'
import { UploadFilled, Lock, Unlock, Document, Tickets, Grid } from '@element-plus/icons-vue'
import { useDataImport } from '@/features/migration/composables/useDataImport'
// icons for import options (follow export page style)
@@ -167,7 +164,7 @@ const {
text-align: left;
margin-top: 25px;
background-color: var(--el-fill-color-lighter);
padding: 20px;
padding: 0px 20px 20px 20px;
border-radius: 8px;
border: 1px dashed var(--el-border-color);
}
@@ -15,6 +15,41 @@ export const csvStrategy = {
* @property {string} category
*/
/**
* 解析一行 CSV,正确处理 RFC 4180 引号字段(含引号的字段、内部双引号转义)
* @param {string} line
* @returns {string[]}
*/
_splitCsvLine(line) {
const fields = []
let i = 0
while (i <= line.length) {
if (i === line.length) { fields.push(''); break }
if (line[i] === '"') {
// 引号字段:读取到匹配的结束引号
let val = ''
i++ // skip opening quote
while (i < line.length) {
if (line[i] === '"') {
if (line[i + 1] === '"') { val += '"'; i += 2 } // 转义的 "" → "
else { i++; break } // 结束引号
} else {
val += line[i++]
}
}
fields.push(val.trim())
if (line[i] === ',') i++ // skip comma
} else {
// 无引号字段:读取到下一个逗号
const end = line.indexOf(',', i)
if (end === -1) { fields.push(line.slice(i).trim()); break }
fields.push(line.slice(i, end).trim())
i = end + 1
}
}
return fields
},
/**
* Decode a generic or bwauth CSV string.
* @param {string} csvText
@@ -24,7 +59,8 @@ export const csvStrategy = {
const lines = csvText.split('\n').filter(line => line.trim())
if (lines.length < 2) return []
const headers = lines[0].toLowerCase().split(',').map(h => h.trim())
// 表头同样做去引号处理并小写
const headers = this._splitCsvLine(lines[0]).map(h => h.toLowerCase())
const rawVault = []
const isBwAuth = headers.includes('login_totp')
@@ -33,9 +69,9 @@ export const csvStrategy = {
if (!isBwAuth && !isGeneric) return []
for (let i = 1; i < lines.length; i++) {
const row = lines[i].split(',').map(c => c.trim())
const row = this._splitCsvLine(lines[i])
const rowData = {}
headers.forEach((h, index) => { rowData[h] = row[index] })
headers.forEach((h, index) => { rowData[h] = row[index] || '' })
if (isBwAuth) {
const totpStr = rowData['login_totp'] || ''
@@ -50,11 +86,15 @@ export const csvStrategy = {
} else {
const secret = rowData['secret'] || ''
if (secret) {
// 本系统导出的 generic csv: name=account, issuer=service
// 兼容其他工具: 若有独立的 account 列则优先
const service = rowData['issuer'] || rowData['name'] || 'Unknown'
const account = rowData['account'] || rowData['name'] || ''
rawVault.push({
service: rowData['issuer'] || rowData['name'] || 'Unknown',
account: rowData['account'] || rowData['name'] || '',
service,
account,
secret: secret.replace(/\s/g, '').toUpperCase(),
algorithm: (rowData['algorithm'] || 'SHA1').toUpperCase().replace('SHA1', 'SHA-1').replace('SHA256', 'SHA-256').replace('SHA512', 'SHA-512'),
algorithm: (rowData['algorithm'] || 'SHA1').toUpperCase().replace(/^SHA-?1$/, 'SHA-1').replace(/^SHA-?256$/, 'SHA-256').replace(/^SHA-?512$/, 'SHA-512'),
digits: parseInt(rowData['digits'] || '6', 10),
period: parseInt(rowData['period'] || '30', 10),
category: ''
+2 -2
View File
@@ -40,8 +40,8 @@ export default defineConfig({
name: '2FAuth Worker',
short_name: '2FAuth Worker',
description: 'A Secure 2FA Management Tool',
theme_color: '#ffffff',
background_color: '#ffffff',
theme_color: '#000000',
background_color: '#000000',
display: 'standalone',
orientation: 'portrait',
start_url: '/',