fix: 完善导入csv格式数据处理逻辑
This commit is contained in:
1 parent
9404e34bd3
commit
5ab24ee862
7 files changed
+107
-37
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
@@ -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: ''
|
||||
|
||||
@@ -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: '/',
|
||||
|
||||
Reference in new issue
Block a user