dev-1.7.0 #294
261
IMPORT_EXPORT_GUIDE.md
Normal file
261
IMPORT_EXPORT_GUIDE.md
Normal file
@@ -0,0 +1,261 @@
|
||||
# Termix 用户数据导入导出指南
|
||||
|
||||
## 概述
|
||||
|
||||
Termix V2 重新实现了用户级数据导入导出功能,支持KEK-DEK架构下的安全数据迁移。
|
||||
|
||||
## 功能特性
|
||||
|
||||
### ✅ 已实现功能
|
||||
- 🔐 **用户级数据导出** - 支持加密和明文格式
|
||||
- 📥 **用户级数据导入** - 支持干运行验证
|
||||
- 🛡️ **数据安全保护** - 基于用户密码的KEK-DEK加密
|
||||
- 📊 **导出预览** - 验证导出内容和大小
|
||||
- 🔍 **OIDC配置加密** - 敏感配置安全存储
|
||||
- 🏭 **生产环境检查** - 启动时安全配置验证
|
||||
|
||||
### 🎯 支持的数据类型
|
||||
- SSH主机配置
|
||||
- SSH凭据(可选)
|
||||
- 文件管理器数据(最近文件、固定文件、快捷方式)
|
||||
- 已忽略的警告
|
||||
|
||||
## API端点
|
||||
|
||||
### 1. 导出用户数据
|
||||
|
||||
```http
|
||||
POST /database/export
|
||||
Authorization: Bearer <jwt_token>
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"format": "encrypted|plaintext", // 可选,默认encrypted
|
||||
"scope": "user_data|all", // 可选,默认user_data
|
||||
"includeCredentials": true, // 可选,默认true
|
||||
"password": "user_password" // 明文导出时必需
|
||||
}
|
||||
```
|
||||
|
||||
**响应**:
|
||||
- 成功:200 + JSON文件下载
|
||||
- 需要密码:400 + `PASSWORD_REQUIRED`
|
||||
- 无权限:401
|
||||
|
||||
### 2. 导入用户数据
|
||||
|
||||
```http
|
||||
POST /database/import
|
||||
Authorization: Bearer <jwt_token>
|
||||
Content-Type: multipart/form-data
|
||||
|
||||
form-data:
|
||||
- file: <导出的JSON文件>
|
||||
- replaceExisting: false // 可选,是否替换现有数据
|
||||
- skipCredentials: false // 可选,是否跳过凭据导入
|
||||
- skipFileManagerData: false // 可选,是否跳过文件管理器数据
|
||||
- dryRun: false // 可选,干运行模式
|
||||
- password: "user_password" // 加密数据导入时必需
|
||||
```
|
||||
|
||||
**响应**:
|
||||
- 成功:200 + 导入统计
|
||||
- 部分成功:207 + 错误详情
|
||||
- 需要密码:400 + `PASSWORD_REQUIRED`
|
||||
|
||||
### 3. 导出预览
|
||||
|
||||
```http
|
||||
POST /database/export/preview
|
||||
Authorization: Bearer <jwt_token>
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"format": "encrypted",
|
||||
"scope": "user_data",
|
||||
"includeCredentials": true
|
||||
}
|
||||
```
|
||||
|
||||
**响应**:
|
||||
```json
|
||||
{
|
||||
"preview": true,
|
||||
"stats": {
|
||||
"version": "v2.0",
|
||||
"username": "admin",
|
||||
"totalRecords": 25,
|
||||
"breakdown": {
|
||||
"sshHosts": 10,
|
||||
"sshCredentials": 5,
|
||||
"fileManagerItems": 8,
|
||||
"dismissedAlerts": 2
|
||||
},
|
||||
"encrypted": true
|
||||
},
|
||||
"estimatedSize": 51234
|
||||
}
|
||||
```
|
||||
|
||||
## 使用示例
|
||||
|
||||
### 导出用户数据(加密)
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8081/database/export \
|
||||
-H "Authorization: Bearer <your_jwt_token>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"format": "encrypted",
|
||||
"includeCredentials": true
|
||||
}' \
|
||||
-o my-termix-backup.json
|
||||
```
|
||||
|
||||
### 导出用户数据(明文,需要密码)
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8081/database/export \
|
||||
-H "Authorization: Bearer <your_jwt_token>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"format": "plaintext",
|
||||
"password": "your_password",
|
||||
"includeCredentials": true
|
||||
}' \
|
||||
-o my-termix-backup-plaintext.json
|
||||
```
|
||||
|
||||
### 导入数据(干运行)
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8081/database/import \
|
||||
-H "Authorization: Bearer <your_jwt_token>" \
|
||||
-F "file=@my-termix-backup.json" \
|
||||
-F "dryRun=true" \
|
||||
-F "password=your_password"
|
||||
```
|
||||
|
||||
### 导入数据(实际执行)
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8081/database/import \
|
||||
-H "Authorization: Bearer <your_jwt_token>" \
|
||||
-F "file=@my-termix-backup.json" \
|
||||
-F "replaceExisting=false" \
|
||||
-F "password=your_password"
|
||||
```
|
||||
|
||||
## 数据格式
|
||||
|
||||
### 导出数据结构
|
||||
|
||||
```typescript
|
||||
interface UserExportData {
|
||||
version: string; // "v2.0"
|
||||
exportedAt: string; // ISO时间戳
|
||||
userId: string; // 用户ID
|
||||
username: string; // 用户名
|
||||
userData: {
|
||||
sshHosts: SSHHost[]; // SSH主机配置
|
||||
sshCredentials: SSHCredential[]; // SSH凭据
|
||||
fileManagerData: { // 文件管理器数据
|
||||
recent: RecentFile[];
|
||||
pinned: PinnedFile[];
|
||||
shortcuts: Shortcut[];
|
||||
};
|
||||
dismissedAlerts: DismissedAlert[]; // 已忽略警告
|
||||
};
|
||||
metadata: {
|
||||
totalRecords: number; // 总记录数
|
||||
encrypted: boolean; // 是否加密
|
||||
exportType: 'user_data' | 'all'; // 导出类型
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## 安全考虑
|
||||
|
||||
### 加密导出
|
||||
- 数据使用用户的KEK-DEK架构加密
|
||||
- 即使导出文件泄露,没有用户密码也无法解密
|
||||
- 推荐用于生产环境数据备份
|
||||
|
||||
### 明文导出
|
||||
- 数据以可读JSON格式导出
|
||||
- 需要用户当前密码验证
|
||||
- 便于数据检查和跨系统迁移
|
||||
- ⚠️ 文件包含敏感信息,使用后应安全删除
|
||||
|
||||
### 导入安全
|
||||
- 导入时验证数据完整性
|
||||
- 支持干运行模式预检查
|
||||
- 自动重新生成ID避免冲突
|
||||
- 加密数据重新使用目标用户的密钥加密
|
||||
|
||||
## 故障排除
|
||||
|
||||
### 常见错误
|
||||
|
||||
1. **`PASSWORD_REQUIRED`** - 明文导出/导入需要密码
|
||||
2. **`Invalid token`** - JWT令牌无效或过期
|
||||
3. **`User data not unlocked`** - 用户数据密钥未解锁
|
||||
4. **`Invalid JSON format`** - 导入文件格式错误
|
||||
5. **`Export validation failed`** - 导出数据结构不完整
|
||||
|
||||
### 调试步骤
|
||||
|
||||
1. 检查JWT令牌是否有效
|
||||
2. 确保用户已登录并解锁数据
|
||||
3. 验证导出文件JSON格式
|
||||
4. 使用干运行模式测试导入
|
||||
5. 查看服务器日志获取详细错误信息
|
||||
|
||||
## 迁移场景
|
||||
|
||||
### 场景1:用户数据备份
|
||||
```bash
|
||||
# 1. 导出加密数据
|
||||
curl -X POST http://localhost:8081/database/export \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-d '{"format":"encrypted"}' \
|
||||
-o backup.json
|
||||
|
||||
# 2. 验证备份
|
||||
curl -X POST http://localhost:8081/database/export/preview \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-d '{}'
|
||||
```
|
||||
|
||||
### 场景2:跨实例迁移
|
||||
```bash
|
||||
# 1. 从源实例导出明文数据
|
||||
curl -X POST http://old-server:8081/database/export \
|
||||
-H "Authorization: Bearer $OLD_TOKEN" \
|
||||
-d '{"format":"plaintext","password":"userpass"}' \
|
||||
-o migration.json
|
||||
|
||||
# 2. 导入到新实例
|
||||
curl -X POST http://new-server:8081/database/import \
|
||||
-H "Authorization: Bearer $NEW_TOKEN" \
|
||||
-F "file=@migration.json" \
|
||||
-F "password=userpass"
|
||||
```
|
||||
|
||||
### 场景3:选择性迁移
|
||||
```bash
|
||||
# 只迁移SSH配置,跳过凭据
|
||||
curl -X POST http://localhost:8081/database/import \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-F "file=@backup.json" \
|
||||
-F "skipCredentials=true" \
|
||||
-F "password=userpass"
|
||||
```
|
||||
|
||||
## 最佳实践
|
||||
|
||||
1. **定期备份**:使用加密格式定期导出用户数据
|
||||
2. **迁移前测试**:使用干运行模式验证导入数据
|
||||
3. **安全处理**:明文导出文件用完后立即删除
|
||||
4. **版本兼容**:检查导出数据版本与目标系统兼容性
|
||||
5. **权限管理**:只允许用户导出自己的数据
|
||||
@@ -14,6 +14,8 @@ import { databaseLogger, apiLogger } from "../utils/logger.js";
|
||||
import { AuthManager } from "../utils/auth-manager.js";
|
||||
import { DataCrypto } from "../utils/data-crypto.js";
|
||||
import { DatabaseFileEncryption } from "../utils/database-file-encryption.js";
|
||||
import { UserDataExport } from "../utils/user-data-export.js";
|
||||
import { UserDataImport } from "../utils/user-data-import.js";
|
||||
|
||||
const app = express();
|
||||
app.use(
|
||||
@@ -391,52 +393,261 @@ app.post("/encryption/regenerate-jwt", async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Database export endpoint - DISABLED in V2 (needs reimplementation)
|
||||
// User data export endpoint - V2 KEK-DEK compatible
|
||||
app.post("/database/export", async (req, res) => {
|
||||
apiLogger.warn("Database export endpoint called but disabled in current architecture", {
|
||||
operation: "database_export_disabled",
|
||||
});
|
||||
try {
|
||||
const authHeader = req.headers["authorization"];
|
||||
if (!authHeader?.startsWith("Bearer ")) {
|
||||
return res.status(401).json({ error: "Missing Authorization header" });
|
||||
}
|
||||
|
||||
res.status(503).json({
|
||||
error: "Database export temporarily disabled during V2 security upgrade",
|
||||
message: "This feature will be reimplemented with proper user-level encryption support",
|
||||
});
|
||||
const token = authHeader.split(" ")[1];
|
||||
const authManager = AuthManager.getInstance();
|
||||
const payload = await authManager.verifyJWTToken(token);
|
||||
|
||||
if (!payload) {
|
||||
return res.status(401).json({ error: "Invalid token" });
|
||||
}
|
||||
|
||||
const userId = payload.userId;
|
||||
const { format = 'encrypted', scope = 'user_data', includeCredentials = true, password } = req.body;
|
||||
|
||||
// 对于明文导出,需要解锁用户数据
|
||||
if (format === 'plaintext') {
|
||||
if (!password) {
|
||||
return res.status(400).json({
|
||||
error: "Password required for plaintext export",
|
||||
code: "PASSWORD_REQUIRED"
|
||||
});
|
||||
}
|
||||
|
||||
const unlocked = await authManager.authenticateUser(userId, password);
|
||||
if (!unlocked) {
|
||||
return res.status(401).json({ error: "Invalid password" });
|
||||
}
|
||||
}
|
||||
|
||||
apiLogger.info("Exporting user data", {
|
||||
operation: "user_data_export_api",
|
||||
userId,
|
||||
format,
|
||||
scope,
|
||||
includeCredentials,
|
||||
});
|
||||
|
||||
const exportData = await UserDataExport.exportUserData(userId, {
|
||||
format,
|
||||
scope,
|
||||
includeCredentials,
|
||||
});
|
||||
|
||||
// 生成导出文件名
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||
const filename = `termix-export-${exportData.username}-${timestamp}.json`;
|
||||
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
|
||||
res.json(exportData);
|
||||
|
||||
apiLogger.success("User data exported successfully", {
|
||||
operation: "user_data_export_api_success",
|
||||
userId,
|
||||
totalRecords: exportData.metadata.totalRecords,
|
||||
format,
|
||||
});
|
||||
} catch (error) {
|
||||
apiLogger.error("User data export failed", error, {
|
||||
operation: "user_data_export_api_failed",
|
||||
});
|
||||
res.status(500).json({
|
||||
error: "Failed to export user data",
|
||||
details: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Database import endpoint - DISABLED (needs reimplementation with user-level encryption)
|
||||
// User data import endpoint - V2 KEK-DEK compatible
|
||||
app.post("/database/import", upload.single("file"), async (req, res) => {
|
||||
// Clean up uploaded file if it exists
|
||||
if (req.file?.path) {
|
||||
try {
|
||||
const authHeader = req.headers["authorization"];
|
||||
if (!authHeader?.startsWith("Bearer ")) {
|
||||
// Clean up uploaded file
|
||||
if (req.file?.path) {
|
||||
try { fs.unlinkSync(req.file.path); } catch {}
|
||||
}
|
||||
return res.status(401).json({ error: "Missing Authorization header" });
|
||||
}
|
||||
|
||||
const token = authHeader.split(" ")[1];
|
||||
const authManager = AuthManager.getInstance();
|
||||
const payload = await authManager.verifyJWTToken(token);
|
||||
|
||||
if (!payload) {
|
||||
// Clean up uploaded file
|
||||
if (req.file?.path) {
|
||||
try { fs.unlinkSync(req.file.path); } catch {}
|
||||
}
|
||||
return res.status(401).json({ error: "Invalid token" });
|
||||
}
|
||||
|
||||
if (!req.file) {
|
||||
return res.status(400).json({ error: "No file uploaded" });
|
||||
}
|
||||
|
||||
const userId = payload.userId;
|
||||
const { replaceExisting = false, skipCredentials = false, skipFileManagerData = false, dryRun = false, password } = req.body;
|
||||
|
||||
apiLogger.info("Importing user data", {
|
||||
operation: "user_data_import_api",
|
||||
userId,
|
||||
filename: req.file.originalname,
|
||||
replaceExisting,
|
||||
skipCredentials,
|
||||
skipFileManagerData,
|
||||
dryRun,
|
||||
});
|
||||
|
||||
// 读取上传的文件
|
||||
const fileContent = fs.readFileSync(req.file.path, 'utf8');
|
||||
|
||||
// 清理上传的临时文件
|
||||
try {
|
||||
fs.unlinkSync(req.file.path);
|
||||
} catch (cleanupError) {
|
||||
apiLogger.warn("Failed to clean up uploaded file during disabled endpoint call", {
|
||||
operation: "file_cleanup_disabled_endpoint",
|
||||
apiLogger.warn("Failed to clean up uploaded file", {
|
||||
operation: "file_cleanup_warning",
|
||||
filePath: req.file.path,
|
||||
});
|
||||
}
|
||||
|
||||
// 解析导入数据
|
||||
let importData;
|
||||
try {
|
||||
importData = JSON.parse(fileContent);
|
||||
} catch (parseError) {
|
||||
return res.status(400).json({ error: "Invalid JSON format in uploaded file" });
|
||||
}
|
||||
|
||||
// 如果导入数据是加密的,需要解锁用户数据
|
||||
if (importData.metadata?.encrypted) {
|
||||
if (!password) {
|
||||
return res.status(400).json({
|
||||
error: "Password required for encrypted import",
|
||||
code: "PASSWORD_REQUIRED"
|
||||
});
|
||||
}
|
||||
|
||||
const unlocked = await authManager.authenticateUser(userId, password);
|
||||
if (!unlocked) {
|
||||
return res.status(401).json({ error: "Invalid password" });
|
||||
}
|
||||
}
|
||||
|
||||
// 执行导入
|
||||
const result = await UserDataImport.importUserData(userId, importData, {
|
||||
replaceExisting: replaceExisting === 'true' || replaceExisting === true,
|
||||
skipCredentials: skipCredentials === 'true' || skipCredentials === true,
|
||||
skipFileManagerData: skipFileManagerData === 'true' || skipFileManagerData === true,
|
||||
dryRun: dryRun === 'true' || dryRun === true,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
apiLogger.success("User data imported successfully", {
|
||||
operation: "user_data_import_api_success",
|
||||
userId,
|
||||
...result.summary,
|
||||
});
|
||||
res.json({
|
||||
success: true,
|
||||
message: dryRun ? "Import validation completed" : "Data imported successfully",
|
||||
summary: result.summary,
|
||||
dryRun: result.dryRun,
|
||||
});
|
||||
} else {
|
||||
apiLogger.warn("User data import completed with errors", {
|
||||
operation: "user_data_import_api_partial",
|
||||
userId,
|
||||
errors: result.summary.errors,
|
||||
});
|
||||
res.status(207).json({
|
||||
success: false,
|
||||
message: "Import completed with errors",
|
||||
summary: result.summary,
|
||||
dryRun: result.dryRun,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
// Clean up uploaded file on error
|
||||
if (req.file?.path) {
|
||||
try { fs.unlinkSync(req.file.path); } catch {}
|
||||
}
|
||||
|
||||
apiLogger.error("User data import failed", error, {
|
||||
operation: "user_data_import_api_failed",
|
||||
});
|
||||
res.status(500).json({
|
||||
error: "Failed to import user data",
|
||||
details: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
|
||||
apiLogger.warn("Database import endpoint called but disabled in current architecture", {
|
||||
operation: "database_import_disabled",
|
||||
});
|
||||
|
||||
res.status(503).json({
|
||||
error: "Database import temporarily disabled during security upgrade",
|
||||
message: "This feature will be reimplemented with proper user-level encryption support",
|
||||
});
|
||||
});
|
||||
|
||||
// Database export info endpoint - DISABLED (needs reimplementation with user-level encryption)
|
||||
app.get("/database/export/:exportPath/info", async (req, res) => {
|
||||
apiLogger.warn("Database export info endpoint called but disabled in current architecture", {
|
||||
operation: "database_export_info_disabled",
|
||||
});
|
||||
// Export preview endpoint - validate export data without downloading
|
||||
app.post("/database/export/preview", async (req, res) => {
|
||||
try {
|
||||
const authHeader = req.headers["authorization"];
|
||||
if (!authHeader?.startsWith("Bearer ")) {
|
||||
return res.status(401).json({ error: "Missing Authorization header" });
|
||||
}
|
||||
|
||||
res.status(503).json({
|
||||
error: "Database export info temporarily disabled during V2 security upgrade",
|
||||
message: "This feature will be reimplemented with proper user-level encryption support",
|
||||
});
|
||||
const token = authHeader.split(" ")[1];
|
||||
const authManager = AuthManager.getInstance();
|
||||
const payload = await authManager.verifyJWTToken(token);
|
||||
|
||||
if (!payload) {
|
||||
return res.status(401).json({ error: "Invalid token" });
|
||||
}
|
||||
|
||||
const userId = payload.userId;
|
||||
const { format = 'encrypted', scope = 'user_data', includeCredentials = true } = req.body;
|
||||
|
||||
apiLogger.info("Generating export preview", {
|
||||
operation: "export_preview_api",
|
||||
userId,
|
||||
format,
|
||||
scope,
|
||||
includeCredentials,
|
||||
});
|
||||
|
||||
// 生成导出数据但不解密敏感字段
|
||||
const exportData = await UserDataExport.exportUserData(userId, {
|
||||
format: 'encrypted', // 始终加密预览
|
||||
scope,
|
||||
includeCredentials,
|
||||
});
|
||||
|
||||
const stats = UserDataExport.getExportStats(exportData);
|
||||
|
||||
res.json({
|
||||
preview: true,
|
||||
stats,
|
||||
estimatedSize: JSON.stringify(exportData).length,
|
||||
});
|
||||
|
||||
apiLogger.success("Export preview generated", {
|
||||
operation: "export_preview_api_success",
|
||||
userId,
|
||||
totalRecords: stats.totalRecords,
|
||||
});
|
||||
} catch (error) {
|
||||
apiLogger.error("Export preview failed", error, {
|
||||
operation: "export_preview_api_failed",
|
||||
});
|
||||
res.status(500).json({
|
||||
error: "Failed to generate export preview",
|
||||
details: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/database/backup", async (req, res) => {
|
||||
|
||||
@@ -19,6 +19,7 @@ import type { Request, Response, NextFunction } from "express";
|
||||
import { authLogger, apiLogger } from "../../utils/logger.js";
|
||||
import { AuthManager } from "../../utils/auth-manager.js";
|
||||
import { UserCrypto } from "../../utils/user-crypto.js";
|
||||
import { DataCrypto } from "../../utils/data-crypto.js";
|
||||
|
||||
// Get auth manager instance
|
||||
const authManager = AuthManager.getInstance();
|
||||
@@ -335,11 +336,44 @@ router.post("/oidc-config", authenticateJWT, async (req, res) => {
|
||||
scopes: scopes || "openid email profile",
|
||||
};
|
||||
|
||||
// 对敏感配置进行加密存储
|
||||
let encryptedConfig;
|
||||
try {
|
||||
// 使用管理员的数据密钥加密OIDC配置
|
||||
const adminDataKey = DataCrypto.getUserDataKey(userId);
|
||||
if (adminDataKey) {
|
||||
encryptedConfig = DataCrypto.encryptRecord("settings", config, userId, adminDataKey);
|
||||
authLogger.info("OIDC configuration encrypted with admin data key", {
|
||||
operation: "oidc_config_encrypt",
|
||||
userId,
|
||||
});
|
||||
} else {
|
||||
// 如果管理员数据未解锁,只加密client_secret
|
||||
encryptedConfig = {
|
||||
...config,
|
||||
client_secret: `encrypted:${Buffer.from(client_secret).toString('base64')}`, // 简单的base64编码
|
||||
};
|
||||
authLogger.warn("OIDC configuration stored with basic encoding - admin should re-save with password", {
|
||||
operation: "oidc_config_basic_encoding",
|
||||
userId,
|
||||
});
|
||||
}
|
||||
} catch (encryptError) {
|
||||
authLogger.error("Failed to encrypt OIDC configuration, storing with basic encoding", encryptError, {
|
||||
operation: "oidc_config_encrypt_failed",
|
||||
userId,
|
||||
});
|
||||
encryptedConfig = {
|
||||
...config,
|
||||
client_secret: `encoded:${Buffer.from(client_secret).toString('base64')}`,
|
||||
};
|
||||
}
|
||||
|
||||
db.$client
|
||||
.prepare(
|
||||
"INSERT OR REPLACE INTO settings (key, value) VALUES ('oidc_config', ?)",
|
||||
)
|
||||
.run(JSON.stringify(config));
|
||||
.run(JSON.stringify(encryptedConfig));
|
||||
authLogger.info("OIDC configuration updated", {
|
||||
operation: "oidc_update",
|
||||
userId,
|
||||
@@ -385,7 +419,61 @@ router.get("/oidc-config", async (req, res) => {
|
||||
if (!row) {
|
||||
return res.json(null);
|
||||
}
|
||||
res.json(JSON.parse((row as any).value));
|
||||
|
||||
let config = JSON.parse((row as any).value);
|
||||
|
||||
// 解密或解码client_secret用于显示
|
||||
if (config.client_secret) {
|
||||
if (config.client_secret.startsWith('encrypted:')) {
|
||||
// 需要管理员权限解密
|
||||
const authHeader = req.headers["authorization"];
|
||||
if (authHeader?.startsWith("Bearer ")) {
|
||||
const token = authHeader.split(" ")[1];
|
||||
const authManager = AuthManager.getInstance();
|
||||
const payload = await authManager.verifyJWTToken(token);
|
||||
|
||||
if (payload) {
|
||||
const userId = payload.userId;
|
||||
const user = await db.select().from(users).where(eq(users.id, userId));
|
||||
|
||||
if (user && user.length > 0 && user[0].is_admin) {
|
||||
try {
|
||||
const adminDataKey = DataCrypto.getUserDataKey(userId);
|
||||
if (adminDataKey) {
|
||||
config = DataCrypto.decryptRecord("settings", config, userId, adminDataKey);
|
||||
} else {
|
||||
// 管理员数据未解锁,隐藏client_secret
|
||||
config.client_secret = "[ENCRYPTED - PASSWORD REQUIRED]";
|
||||
}
|
||||
} catch (decryptError) {
|
||||
authLogger.warn("Failed to decrypt OIDC config for admin", {
|
||||
operation: "oidc_config_decrypt_failed",
|
||||
userId,
|
||||
});
|
||||
config.client_secret = "[ENCRYPTED - DECRYPTION FAILED]";
|
||||
}
|
||||
} else {
|
||||
config.client_secret = "[ENCRYPTED - ADMIN ONLY]";
|
||||
}
|
||||
} else {
|
||||
config.client_secret = "[ENCRYPTED - AUTH REQUIRED]";
|
||||
}
|
||||
} else {
|
||||
config.client_secret = "[ENCRYPTED - AUTH REQUIRED]";
|
||||
}
|
||||
} else if (config.client_secret.startsWith('encoded:')) {
|
||||
// base64解码
|
||||
try {
|
||||
const decoded = Buffer.from(config.client_secret.substring(8), 'base64').toString('utf8');
|
||||
config.client_secret = decoded;
|
||||
} catch {
|
||||
config.client_secret = "[ENCODING ERROR]";
|
||||
}
|
||||
}
|
||||
// 否则是明文,直接返回
|
||||
}
|
||||
|
||||
res.json(config);
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to get OIDC config", err);
|
||||
res.status(500).json({ error: "Failed to get OIDC config" });
|
||||
|
||||
@@ -15,8 +15,62 @@ import "dotenv/config";
|
||||
version: version,
|
||||
});
|
||||
|
||||
// 生产环境安全检查
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
systemLogger.info("Running production environment security checks...", {
|
||||
operation: "security_checks",
|
||||
});
|
||||
|
||||
const securityIssues: string[] = [];
|
||||
|
||||
// 检查系统主密钥
|
||||
if (!process.env.SYSTEM_MASTER_KEY) {
|
||||
securityIssues.push("SYSTEM_MASTER_KEY environment variable is required in production");
|
||||
} else if (process.env.SYSTEM_MASTER_KEY.length < 64) {
|
||||
securityIssues.push("SYSTEM_MASTER_KEY should be at least 64 characters in production");
|
||||
}
|
||||
|
||||
// 检查数据库文件加密
|
||||
if (process.env.DB_FILE_ENCRYPTION === 'false') {
|
||||
securityIssues.push("Database file encryption should be enabled in production");
|
||||
}
|
||||
|
||||
// 检查JWT移密
|
||||
if (!process.env.JWT_SECRET) {
|
||||
systemLogger.info("JWT_SECRET not set - will use encrypted storage", {
|
||||
operation: "security_checks",
|
||||
note: "Using encrypted JWT storage"
|
||||
});
|
||||
}
|
||||
|
||||
// 检查CORS配置警告
|
||||
systemLogger.warn("Production deployment detected - ensure CORS is properly configured", {
|
||||
operation: "security_checks",
|
||||
warning: "Verify frontend domain whitelist"
|
||||
});
|
||||
|
||||
if (securityIssues.length > 0) {
|
||||
systemLogger.error("SECURITY ISSUES DETECTED IN PRODUCTION:", {
|
||||
operation: "security_checks_failed",
|
||||
issues: securityIssues,
|
||||
});
|
||||
for (const issue of securityIssues) {
|
||||
systemLogger.error(`- ${issue}`, { operation: "security_issue" });
|
||||
}
|
||||
systemLogger.error("Fix these issues before running in production!", {
|
||||
operation: "security_checks_failed",
|
||||
});
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
systemLogger.success("Production security checks passed", {
|
||||
operation: "security_checks_complete",
|
||||
});
|
||||
}
|
||||
|
||||
systemLogger.info("Initializing backend services...", {
|
||||
operation: "startup",
|
||||
environment: process.env.NODE_ENV || "development",
|
||||
});
|
||||
|
||||
// Initialize simplified authentication system
|
||||
|
||||
216
src/backend/utils/import-export-test.ts
Normal file
216
src/backend/utils/import-export-test.ts
Normal file
@@ -0,0 +1,216 @@
|
||||
import { UserDataExport, type UserExportData } from "./user-data-export.js";
|
||||
import { UserDataImport, type ImportResult } from "./user-data-import.js";
|
||||
import { databaseLogger } from "./logger.js";
|
||||
|
||||
/**
|
||||
* 导入导出功能测试
|
||||
*
|
||||
* Linus原则:简单的冒烟测试,确保基本功能工作
|
||||
*/
|
||||
class ImportExportTest {
|
||||
|
||||
/**
|
||||
* 测试导出功能
|
||||
*/
|
||||
static async testExport(userId: string): Promise<boolean> {
|
||||
try {
|
||||
databaseLogger.info("Testing user data export functionality", {
|
||||
operation: "import_export_test",
|
||||
test: "export",
|
||||
userId,
|
||||
});
|
||||
|
||||
// 测试加密导出
|
||||
const encryptedExport = await UserDataExport.exportUserData(userId, {
|
||||
format: 'encrypted',
|
||||
scope: 'user_data',
|
||||
includeCredentials: true,
|
||||
});
|
||||
|
||||
// 验证导出数据结构
|
||||
const validation = UserDataExport.validateExportData(encryptedExport);
|
||||
if (!validation.valid) {
|
||||
databaseLogger.error("Export validation failed", {
|
||||
operation: "import_export_test",
|
||||
test: "export_validation",
|
||||
errors: validation.errors,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
// 获取统计信息
|
||||
const stats = UserDataExport.getExportStats(encryptedExport);
|
||||
|
||||
databaseLogger.success("Export test completed successfully", {
|
||||
operation: "import_export_test",
|
||||
test: "export_success",
|
||||
totalRecords: stats.totalRecords,
|
||||
breakdown: stats.breakdown,
|
||||
encrypted: stats.encrypted,
|
||||
});
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
databaseLogger.error("Export test failed", error, {
|
||||
operation: "import_export_test",
|
||||
test: "export_failed",
|
||||
userId,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试导入功能(dry-run)
|
||||
*/
|
||||
static async testImportDryRun(userId: string, exportData: UserExportData): Promise<boolean> {
|
||||
try {
|
||||
databaseLogger.info("Testing user data import functionality (dry-run)", {
|
||||
operation: "import_export_test",
|
||||
test: "import_dry_run",
|
||||
userId,
|
||||
});
|
||||
|
||||
// 执行dry-run导入
|
||||
const result = await UserDataImport.importUserData(userId, exportData, {
|
||||
dryRun: true,
|
||||
replaceExisting: false,
|
||||
skipCredentials: false,
|
||||
skipFileManagerData: false,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
databaseLogger.success("Import dry-run test completed successfully", {
|
||||
operation: "import_export_test",
|
||||
test: "import_dry_run_success",
|
||||
summary: result.summary,
|
||||
});
|
||||
return true;
|
||||
} else {
|
||||
databaseLogger.error("Import dry-run test failed", {
|
||||
operation: "import_export_test",
|
||||
test: "import_dry_run_failed",
|
||||
errors: result.summary.errors,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
databaseLogger.error("Import dry-run test failed with exception", error, {
|
||||
operation: "import_export_test",
|
||||
test: "import_dry_run_exception",
|
||||
userId,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 运行完整的导入导出测试
|
||||
*/
|
||||
static async runFullTest(userId: string): Promise<boolean> {
|
||||
try {
|
||||
databaseLogger.info("Starting full import/export test suite", {
|
||||
operation: "import_export_test",
|
||||
test: "full_suite",
|
||||
userId,
|
||||
});
|
||||
|
||||
// 1. 测试导出
|
||||
const exportSuccess = await this.testExport(userId);
|
||||
if (!exportSuccess) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 2. 获取导出数据用于导入测试
|
||||
const exportData = await UserDataExport.exportUserData(userId, {
|
||||
format: 'encrypted',
|
||||
scope: 'user_data',
|
||||
includeCredentials: true,
|
||||
});
|
||||
|
||||
// 3. 测试导入(dry-run)
|
||||
const importSuccess = await this.testImportDryRun(userId, exportData);
|
||||
if (!importSuccess) {
|
||||
return false;
|
||||
}
|
||||
|
||||
databaseLogger.success("Full import/export test suite completed successfully", {
|
||||
operation: "import_export_test",
|
||||
test: "full_suite_success",
|
||||
userId,
|
||||
});
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
databaseLogger.error("Full import/export test suite failed", error, {
|
||||
operation: "import_export_test",
|
||||
test: "full_suite_failed",
|
||||
userId,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证JSON序列化和反序列化
|
||||
*/
|
||||
static async testJSONSerialization(userId: string): Promise<boolean> {
|
||||
try {
|
||||
databaseLogger.info("Testing JSON serialization/deserialization", {
|
||||
operation: "import_export_test",
|
||||
test: "json_serialization",
|
||||
userId,
|
||||
});
|
||||
|
||||
// 导出为JSON字符串
|
||||
const jsonString = await UserDataExport.exportUserDataToJSON(userId, {
|
||||
format: 'encrypted',
|
||||
pretty: true,
|
||||
});
|
||||
|
||||
// 解析JSON
|
||||
const parsedData = JSON.parse(jsonString);
|
||||
|
||||
// 验证解析后的数据
|
||||
const validation = UserDataExport.validateExportData(parsedData);
|
||||
if (!validation.valid) {
|
||||
databaseLogger.error("JSON serialization validation failed", {
|
||||
operation: "import_export_test",
|
||||
test: "json_validation_failed",
|
||||
errors: validation.errors,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
// 测试从JSON导入(dry-run)
|
||||
const importResult = await UserDataImport.importUserDataFromJSON(userId, jsonString, {
|
||||
dryRun: true,
|
||||
});
|
||||
|
||||
if (importResult.success) {
|
||||
databaseLogger.success("JSON serialization test completed successfully", {
|
||||
operation: "import_export_test",
|
||||
test: "json_serialization_success",
|
||||
jsonSize: jsonString.length,
|
||||
});
|
||||
return true;
|
||||
} else {
|
||||
databaseLogger.error("JSON import test failed", {
|
||||
operation: "import_export_test",
|
||||
test: "json_import_failed",
|
||||
errors: importResult.summary.errors,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
databaseLogger.error("JSON serialization test failed", error, {
|
||||
operation: "import_export_test",
|
||||
test: "json_serialization_exception",
|
||||
userId,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { ImportExportTest };
|
||||
250
src/backend/utils/user-data-export.ts
Normal file
250
src/backend/utils/user-data-export.ts
Normal file
@@ -0,0 +1,250 @@
|
||||
import { db } from "../database/db/index.js";
|
||||
import { users, sshData, sshCredentials, fileManagerRecent, fileManagerPinned, fileManagerShortcuts, dismissedAlerts } from "../database/db/schema.js";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { DataCrypto } from "./data-crypto.js";
|
||||
import { databaseLogger } from "./logger.js";
|
||||
import crypto from "crypto";
|
||||
|
||||
interface UserExportData {
|
||||
version: string;
|
||||
exportedAt: string;
|
||||
userId: string;
|
||||
username: string;
|
||||
userData: {
|
||||
sshHosts: any[];
|
||||
sshCredentials: any[];
|
||||
fileManagerData: {
|
||||
recent: any[];
|
||||
pinned: any[];
|
||||
shortcuts: any[];
|
||||
};
|
||||
dismissedAlerts: any[];
|
||||
};
|
||||
metadata: {
|
||||
totalRecords: number;
|
||||
encrypted: boolean;
|
||||
exportType: 'user_data' | 'system_config' | 'all';
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* UserDataExport - 用户级数据导入导出
|
||||
*
|
||||
* Linus原则:
|
||||
* - 用户拥有自己的数据,应该能自由导出
|
||||
* - 简单直接,没有复杂的权限检查
|
||||
* - 支持加密和明文两种格式
|
||||
* - 不破坏现有系统架构
|
||||
*/
|
||||
class UserDataExport {
|
||||
private static readonly EXPORT_VERSION = "v2.0";
|
||||
|
||||
/**
|
||||
* 导出用户数据
|
||||
*/
|
||||
static async exportUserData(
|
||||
userId: string,
|
||||
options: {
|
||||
format?: 'encrypted' | 'plaintext';
|
||||
scope?: 'user_data' | 'all';
|
||||
includeCredentials?: boolean;
|
||||
} = {}
|
||||
): Promise<UserExportData> {
|
||||
const { format = 'encrypted', scope = 'user_data', includeCredentials = true } = options;
|
||||
|
||||
try {
|
||||
databaseLogger.info("Starting user data export", {
|
||||
operation: "user_data_export",
|
||||
userId,
|
||||
format,
|
||||
scope,
|
||||
includeCredentials,
|
||||
});
|
||||
|
||||
// 验证用户存在
|
||||
const user = await db.select().from(users).where(eq(users.id, userId));
|
||||
if (!user || user.length === 0) {
|
||||
throw new Error(`User not found: ${userId}`);
|
||||
}
|
||||
|
||||
const userRecord = user[0];
|
||||
|
||||
// 获取用户数据密钥(如果需要解密)
|
||||
let userDataKey: Buffer | null = null;
|
||||
if (format === 'plaintext') {
|
||||
userDataKey = DataCrypto.getUserDataKey(userId);
|
||||
if (!userDataKey) {
|
||||
throw new Error("User data not unlocked - password required for plaintext export");
|
||||
}
|
||||
}
|
||||
|
||||
// 导出SSH主机配置
|
||||
const sshHosts = await db.select().from(sshData).where(eq(sshData.userId, userId));
|
||||
const processedSshHosts = format === 'plaintext' && userDataKey
|
||||
? sshHosts.map(host => DataCrypto.decryptRecord("ssh_data", host, userId, userDataKey!))
|
||||
: sshHosts;
|
||||
|
||||
// 导出SSH凭据(如果包含)
|
||||
let sshCredentialsData: any[] = [];
|
||||
if (includeCredentials) {
|
||||
const credentials = await db.select().from(sshCredentials).where(eq(sshCredentials.userId, userId));
|
||||
sshCredentialsData = format === 'plaintext' && userDataKey
|
||||
? credentials.map(cred => DataCrypto.decryptRecord("ssh_credentials", cred, userId, userDataKey!))
|
||||
: credentials;
|
||||
}
|
||||
|
||||
// 导出文件管理器数据
|
||||
const [recentFiles, pinnedFiles, shortcuts] = await Promise.all([
|
||||
db.select().from(fileManagerRecent).where(eq(fileManagerRecent.userId, userId)),
|
||||
db.select().from(fileManagerPinned).where(eq(fileManagerPinned.userId, userId)),
|
||||
db.select().from(fileManagerShortcuts).where(eq(fileManagerShortcuts.userId, userId)),
|
||||
]);
|
||||
|
||||
// 导出已忽略的警告
|
||||
const alerts = await db.select().from(dismissedAlerts).where(eq(dismissedAlerts.userId, userId));
|
||||
|
||||
// 构建导出数据
|
||||
const exportData: UserExportData = {
|
||||
version: this.EXPORT_VERSION,
|
||||
exportedAt: new Date().toISOString(),
|
||||
userId: userRecord.id,
|
||||
username: userRecord.username,
|
||||
userData: {
|
||||
sshHosts: processedSshHosts,
|
||||
sshCredentials: sshCredentialsData,
|
||||
fileManagerData: {
|
||||
recent: recentFiles,
|
||||
pinned: pinnedFiles,
|
||||
shortcuts: shortcuts,
|
||||
},
|
||||
dismissedAlerts: alerts,
|
||||
},
|
||||
metadata: {
|
||||
totalRecords: processedSshHosts.length + sshCredentialsData.length + recentFiles.length + pinnedFiles.length + shortcuts.length + alerts.length,
|
||||
encrypted: format === 'encrypted',
|
||||
exportType: scope,
|
||||
},
|
||||
};
|
||||
|
||||
databaseLogger.success("User data export completed", {
|
||||
operation: "user_data_export_complete",
|
||||
userId,
|
||||
totalRecords: exportData.metadata.totalRecords,
|
||||
format,
|
||||
sshHosts: processedSshHosts.length,
|
||||
sshCredentials: sshCredentialsData.length,
|
||||
});
|
||||
|
||||
return exportData;
|
||||
} catch (error) {
|
||||
databaseLogger.error("User data export failed", error, {
|
||||
operation: "user_data_export_failed",
|
||||
userId,
|
||||
format,
|
||||
scope,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出为JSON字符串
|
||||
*/
|
||||
static async exportUserDataToJSON(
|
||||
userId: string,
|
||||
options: {
|
||||
format?: 'encrypted' | 'plaintext';
|
||||
scope?: 'user_data' | 'all';
|
||||
includeCredentials?: boolean;
|
||||
pretty?: boolean;
|
||||
} = {}
|
||||
): Promise<string> {
|
||||
const { pretty = true } = options;
|
||||
const exportData = await this.exportUserData(userId, options);
|
||||
return JSON.stringify(exportData, null, pretty ? 2 : 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证导出数据格式
|
||||
*/
|
||||
static validateExportData(data: any): { valid: boolean; errors: string[] } {
|
||||
const errors: string[] = [];
|
||||
|
||||
if (!data || typeof data !== 'object') {
|
||||
errors.push("Export data must be an object");
|
||||
return { valid: false, errors };
|
||||
}
|
||||
|
||||
if (!data.version) {
|
||||
errors.push("Missing version field");
|
||||
}
|
||||
|
||||
if (!data.userId) {
|
||||
errors.push("Missing userId field");
|
||||
}
|
||||
|
||||
if (!data.userData || typeof data.userData !== 'object') {
|
||||
errors.push("Missing or invalid userData field");
|
||||
}
|
||||
|
||||
if (!data.metadata || typeof data.metadata !== 'object') {
|
||||
errors.push("Missing or invalid metadata field");
|
||||
}
|
||||
|
||||
// 检查必需的数据字段
|
||||
if (data.userData) {
|
||||
const requiredFields = ['sshHosts', 'sshCredentials', 'fileManagerData', 'dismissedAlerts'];
|
||||
for (const field of requiredFields) {
|
||||
if (!Array.isArray(data.userData[field]) && !(field === 'fileManagerData' && typeof data.userData[field] === 'object')) {
|
||||
errors.push(`Missing or invalid userData.${field} field`);
|
||||
}
|
||||
}
|
||||
|
||||
if (data.userData.fileManagerData && typeof data.userData.fileManagerData === 'object') {
|
||||
const fmFields = ['recent', 'pinned', 'shortcuts'];
|
||||
for (const field of fmFields) {
|
||||
if (!Array.isArray(data.userData.fileManagerData[field])) {
|
||||
errors.push(`Missing or invalid userData.fileManagerData.${field} field`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { valid: errors.length === 0, errors };
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取导出数据统计信息
|
||||
*/
|
||||
static getExportStats(data: UserExportData): {
|
||||
version: string;
|
||||
exportedAt: string;
|
||||
username: string;
|
||||
totalRecords: number;
|
||||
breakdown: {
|
||||
sshHosts: number;
|
||||
sshCredentials: number;
|
||||
fileManagerItems: number;
|
||||
dismissedAlerts: number;
|
||||
};
|
||||
encrypted: boolean;
|
||||
} {
|
||||
return {
|
||||
version: data.version,
|
||||
exportedAt: data.exportedAt,
|
||||
username: data.username,
|
||||
totalRecords: data.metadata.totalRecords,
|
||||
breakdown: {
|
||||
sshHosts: data.userData.sshHosts.length,
|
||||
sshCredentials: data.userData.sshCredentials.length,
|
||||
fileManagerItems: data.userData.fileManagerData.recent.length +
|
||||
data.userData.fileManagerData.pinned.length +
|
||||
data.userData.fileManagerData.shortcuts.length,
|
||||
dismissedAlerts: data.userData.dismissedAlerts.length,
|
||||
},
|
||||
encrypted: data.metadata.encrypted,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export { UserDataExport, type UserExportData };
|
||||
424
src/backend/utils/user-data-import.ts
Normal file
424
src/backend/utils/user-data-import.ts
Normal file
@@ -0,0 +1,424 @@
|
||||
import { db } from "../database/db/index.js";
|
||||
import { users, sshData, sshCredentials, fileManagerRecent, fileManagerPinned, fileManagerShortcuts, dismissedAlerts } from "../database/db/schema.js";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { DataCrypto } from "./data-crypto.js";
|
||||
import { UserDataExport, type UserExportData } from "./user-data-export.js";
|
||||
import { databaseLogger } from "./logger.js";
|
||||
import { nanoid } from "nanoid";
|
||||
|
||||
interface ImportOptions {
|
||||
replaceExisting?: boolean;
|
||||
skipCredentials?: boolean;
|
||||
skipFileManagerData?: boolean;
|
||||
dryRun?: boolean;
|
||||
}
|
||||
|
||||
interface ImportResult {
|
||||
success: boolean;
|
||||
summary: {
|
||||
sshHostsImported: number;
|
||||
sshCredentialsImported: number;
|
||||
fileManagerItemsImported: number;
|
||||
dismissedAlertsImported: number;
|
||||
skippedItems: number;
|
||||
errors: string[];
|
||||
};
|
||||
dryRun: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* UserDataImport - 用户数据导入
|
||||
*
|
||||
* Linus原则:
|
||||
* - 导入不应该破坏现有数据(除非明确要求)
|
||||
* - 支持dry-run模式验证
|
||||
* - 处理ID冲突的简单策略:重新生成
|
||||
* - 错误处理要明确,不能静默失败
|
||||
*/
|
||||
class UserDataImport {
|
||||
|
||||
/**
|
||||
* 导入用户数据
|
||||
*/
|
||||
static async importUserData(
|
||||
targetUserId: string,
|
||||
exportData: UserExportData,
|
||||
options: ImportOptions = {}
|
||||
): Promise<ImportResult> {
|
||||
const {
|
||||
replaceExisting = false,
|
||||
skipCredentials = false,
|
||||
skipFileManagerData = false,
|
||||
dryRun = false
|
||||
} = options;
|
||||
|
||||
try {
|
||||
databaseLogger.info("Starting user data import", {
|
||||
operation: "user_data_import",
|
||||
targetUserId,
|
||||
sourceUserId: exportData.userId,
|
||||
sourceUsername: exportData.username,
|
||||
dryRun,
|
||||
replaceExisting,
|
||||
skipCredentials,
|
||||
skipFileManagerData,
|
||||
});
|
||||
|
||||
// 验证目标用户存在
|
||||
const targetUser = await db.select().from(users).where(eq(users.id, targetUserId));
|
||||
if (!targetUser || targetUser.length === 0) {
|
||||
throw new Error(`Target user not found: ${targetUserId}`);
|
||||
}
|
||||
|
||||
// 验证导出数据格式
|
||||
const validation = UserDataExport.validateExportData(exportData);
|
||||
if (!validation.valid) {
|
||||
throw new Error(`Invalid export data: ${validation.errors.join(', ')}`);
|
||||
}
|
||||
|
||||
// 验证用户数据已解锁(如果数据是加密的)
|
||||
let userDataKey: Buffer | null = null;
|
||||
if (exportData.metadata.encrypted) {
|
||||
userDataKey = DataCrypto.getUserDataKey(targetUserId);
|
||||
if (!userDataKey) {
|
||||
throw new Error("Target user data not unlocked - password required for encrypted import");
|
||||
}
|
||||
}
|
||||
|
||||
const result: ImportResult = {
|
||||
success: false,
|
||||
summary: {
|
||||
sshHostsImported: 0,
|
||||
sshCredentialsImported: 0,
|
||||
fileManagerItemsImported: 0,
|
||||
dismissedAlertsImported: 0,
|
||||
skippedItems: 0,
|
||||
errors: [],
|
||||
},
|
||||
dryRun,
|
||||
};
|
||||
|
||||
// 导入SSH主机配置
|
||||
if (exportData.userData.sshHosts && exportData.userData.sshHosts.length > 0) {
|
||||
const importStats = await this.importSshHosts(
|
||||
targetUserId,
|
||||
exportData.userData.sshHosts,
|
||||
{ replaceExisting, dryRun, userDataKey }
|
||||
);
|
||||
result.summary.sshHostsImported = importStats.imported;
|
||||
result.summary.skippedItems += importStats.skipped;
|
||||
result.summary.errors.push(...importStats.errors);
|
||||
}
|
||||
|
||||
// 导入SSH凭据
|
||||
if (!skipCredentials && exportData.userData.sshCredentials && exportData.userData.sshCredentials.length > 0) {
|
||||
const importStats = await this.importSshCredentials(
|
||||
targetUserId,
|
||||
exportData.userData.sshCredentials,
|
||||
{ replaceExisting, dryRun, userDataKey }
|
||||
);
|
||||
result.summary.sshCredentialsImported = importStats.imported;
|
||||
result.summary.skippedItems += importStats.skipped;
|
||||
result.summary.errors.push(...importStats.errors);
|
||||
}
|
||||
|
||||
// 导入文件管理器数据
|
||||
if (!skipFileManagerData && exportData.userData.fileManagerData) {
|
||||
const importStats = await this.importFileManagerData(
|
||||
targetUserId,
|
||||
exportData.userData.fileManagerData,
|
||||
{ replaceExisting, dryRun }
|
||||
);
|
||||
result.summary.fileManagerItemsImported = importStats.imported;
|
||||
result.summary.skippedItems += importStats.skipped;
|
||||
result.summary.errors.push(...importStats.errors);
|
||||
}
|
||||
|
||||
// 导入忽略的警告
|
||||
if (exportData.userData.dismissedAlerts && exportData.userData.dismissedAlerts.length > 0) {
|
||||
const importStats = await this.importDismissedAlerts(
|
||||
targetUserId,
|
||||
exportData.userData.dismissedAlerts,
|
||||
{ replaceExisting, dryRun }
|
||||
);
|
||||
result.summary.dismissedAlertsImported = importStats.imported;
|
||||
result.summary.skippedItems += importStats.skipped;
|
||||
result.summary.errors.push(...importStats.errors);
|
||||
}
|
||||
|
||||
result.success = result.summary.errors.length === 0;
|
||||
|
||||
databaseLogger.success("User data import completed", {
|
||||
operation: "user_data_import_complete",
|
||||
targetUserId,
|
||||
dryRun,
|
||||
...result.summary,
|
||||
});
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
databaseLogger.error("User data import failed", error, {
|
||||
operation: "user_data_import_failed",
|
||||
targetUserId,
|
||||
dryRun,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 导入SSH主机配置
|
||||
*/
|
||||
private static async importSshHosts(
|
||||
targetUserId: string,
|
||||
sshHosts: any[],
|
||||
options: { replaceExisting: boolean; dryRun: boolean; userDataKey: Buffer | null }
|
||||
) {
|
||||
let imported = 0;
|
||||
let skipped = 0;
|
||||
const errors: string[] = [];
|
||||
|
||||
for (const host of sshHosts) {
|
||||
try {
|
||||
if (options.dryRun) {
|
||||
imported++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 重新生成ID避免冲突
|
||||
const newHostData = {
|
||||
...host,
|
||||
id: undefined, // 让数据库自动生成
|
||||
userId: targetUserId,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
// 如果数据需要重新加密
|
||||
let processedHostData = newHostData;
|
||||
if (options.userDataKey) {
|
||||
processedHostData = DataCrypto.encryptRecord("ssh_data", newHostData, targetUserId, options.userDataKey);
|
||||
}
|
||||
|
||||
await db.insert(sshData).values(processedHostData);
|
||||
imported++;
|
||||
} catch (error) {
|
||||
errors.push(`SSH host import failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
skipped++;
|
||||
}
|
||||
}
|
||||
|
||||
return { imported, skipped, errors };
|
||||
}
|
||||
|
||||
/**
|
||||
* 导入SSH凭据
|
||||
*/
|
||||
private static async importSshCredentials(
|
||||
targetUserId: string,
|
||||
credentials: any[],
|
||||
options: { replaceExisting: boolean; dryRun: boolean; userDataKey: Buffer | null }
|
||||
) {
|
||||
let imported = 0;
|
||||
let skipped = 0;
|
||||
const errors: string[] = [];
|
||||
|
||||
for (const credential of credentials) {
|
||||
try {
|
||||
if (options.dryRun) {
|
||||
imported++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 重新生成ID避免冲突
|
||||
const newCredentialData = {
|
||||
...credential,
|
||||
id: undefined, // 让数据库自动生成
|
||||
userId: targetUserId,
|
||||
usageCount: 0, // 重置使用计数
|
||||
lastUsed: null,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
// 如果数据需要重新加密
|
||||
let processedCredentialData = newCredentialData;
|
||||
if (options.userDataKey) {
|
||||
processedCredentialData = DataCrypto.encryptRecord("ssh_credentials", newCredentialData, targetUserId, options.userDataKey);
|
||||
}
|
||||
|
||||
await db.insert(sshCredentials).values(processedCredentialData);
|
||||
imported++;
|
||||
} catch (error) {
|
||||
errors.push(`SSH credential import failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
skipped++;
|
||||
}
|
||||
}
|
||||
|
||||
return { imported, skipped, errors };
|
||||
}
|
||||
|
||||
/**
|
||||
* 导入文件管理器数据
|
||||
*/
|
||||
private static async importFileManagerData(
|
||||
targetUserId: string,
|
||||
fileManagerData: any,
|
||||
options: { replaceExisting: boolean; dryRun: boolean }
|
||||
) {
|
||||
let imported = 0;
|
||||
let skipped = 0;
|
||||
const errors: string[] = [];
|
||||
|
||||
try {
|
||||
// 导入最近文件
|
||||
if (fileManagerData.recent && Array.isArray(fileManagerData.recent)) {
|
||||
for (const item of fileManagerData.recent) {
|
||||
try {
|
||||
if (!options.dryRun) {
|
||||
const newItem = {
|
||||
...item,
|
||||
id: undefined,
|
||||
userId: targetUserId,
|
||||
lastOpened: new Date().toISOString(),
|
||||
};
|
||||
await db.insert(fileManagerRecent).values(newItem);
|
||||
}
|
||||
imported++;
|
||||
} catch (error) {
|
||||
errors.push(`Recent file import failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
skipped++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 导入固定文件
|
||||
if (fileManagerData.pinned && Array.isArray(fileManagerData.pinned)) {
|
||||
for (const item of fileManagerData.pinned) {
|
||||
try {
|
||||
if (!options.dryRun) {
|
||||
const newItem = {
|
||||
...item,
|
||||
id: undefined,
|
||||
userId: targetUserId,
|
||||
pinnedAt: new Date().toISOString(),
|
||||
};
|
||||
await db.insert(fileManagerPinned).values(newItem);
|
||||
}
|
||||
imported++;
|
||||
} catch (error) {
|
||||
errors.push(`Pinned file import failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
skipped++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 导入快捷方式
|
||||
if (fileManagerData.shortcuts && Array.isArray(fileManagerData.shortcuts)) {
|
||||
for (const item of fileManagerData.shortcuts) {
|
||||
try {
|
||||
if (!options.dryRun) {
|
||||
const newItem = {
|
||||
...item,
|
||||
id: undefined,
|
||||
userId: targetUserId,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
await db.insert(fileManagerShortcuts).values(newItem);
|
||||
}
|
||||
imported++;
|
||||
} catch (error) {
|
||||
errors.push(`Shortcut import failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
skipped++;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
errors.push(`File manager data import failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
|
||||
return { imported, skipped, errors };
|
||||
}
|
||||
|
||||
/**
|
||||
* 导入忽略的警告
|
||||
*/
|
||||
private static async importDismissedAlerts(
|
||||
targetUserId: string,
|
||||
alerts: any[],
|
||||
options: { replaceExisting: boolean; dryRun: boolean }
|
||||
) {
|
||||
let imported = 0;
|
||||
let skipped = 0;
|
||||
const errors: string[] = [];
|
||||
|
||||
for (const alert of alerts) {
|
||||
try {
|
||||
if (options.dryRun) {
|
||||
imported++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 检查是否已存在相同的警告
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(dismissedAlerts)
|
||||
.where(
|
||||
and(
|
||||
eq(dismissedAlerts.userId, targetUserId),
|
||||
eq(dismissedAlerts.alertId, alert.alertId)
|
||||
)
|
||||
);
|
||||
|
||||
if (existing.length > 0 && !options.replaceExisting) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const newAlert = {
|
||||
...alert,
|
||||
id: undefined,
|
||||
userId: targetUserId,
|
||||
dismissedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
if (existing.length > 0 && options.replaceExisting) {
|
||||
await db
|
||||
.update(dismissedAlerts)
|
||||
.set(newAlert)
|
||||
.where(eq(dismissedAlerts.id, existing[0].id));
|
||||
} else {
|
||||
await db.insert(dismissedAlerts).values(newAlert);
|
||||
}
|
||||
|
||||
imported++;
|
||||
} catch (error) {
|
||||
errors.push(`Dismissed alert import failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
skipped++;
|
||||
}
|
||||
}
|
||||
|
||||
return { imported, skipped, errors };
|
||||
}
|
||||
|
||||
/**
|
||||
* 从JSON字符串导入
|
||||
*/
|
||||
static async importUserDataFromJSON(
|
||||
targetUserId: string,
|
||||
jsonData: string,
|
||||
options: ImportOptions = {}
|
||||
): Promise<ImportResult> {
|
||||
try {
|
||||
const exportData: UserExportData = JSON.parse(jsonData);
|
||||
return await this.importUserData(targetUserId, exportData, options);
|
||||
} catch (error) {
|
||||
if (error instanceof SyntaxError) {
|
||||
throw new Error("Invalid JSON format in import data");
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { UserDataImport, type ImportOptions, type ImportResult };
|
||||
Reference in New Issue
Block a user