CLEANUP: Remove obsolete documentation and component files

- Remove IMPORT_EXPORT_GUIDE.md (obsolete documentation)
- Remove unified_key_section.tsx (unused component)

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
ZacharyZcR
2025-09-22 22:19:22 +08:00
parent e4317667ac
commit fc590ed201
3 changed files with 0 additions and 557 deletions

View File

@@ -1,2 +0,0 @@
VERSION=1.6.0
VITE_API_HOST=localhost

View File

@@ -1,261 +0,0 @@
# 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. **权限管理**:只允许用户导出自己的数据

View File

@@ -1,294 +0,0 @@
<TabsContent value="key">
<div className="space-y-6">
{/* Private Key Section */}
<div className="space-y-4">
<FormLabel className="text-sm font-medium">
{t("credentials.sshPrivateKey")}
</FormLabel>
<div className="grid grid-cols-2 gap-4">
{/* File Upload */}
<Controller
control={form.control}
name="key"
render={({ field }) => (
<FormItem className="flex flex-col">
<FormLabel className="text-xs text-muted-foreground">
{t("hosts.uploadFile")}
</FormLabel>
<FormControl>
<div className="relative inline-block w-full">
<input
id="key-upload"
type="file"
accept="*,.pem,.key,.txt,.ppk"
onChange={async (e) => {
const file = e.target.files?.[0];
if (file) {
field.onChange(file);
try {
const fileContent = await file.text();
debouncedKeyDetection(
fileContent,
form.watch("keyPassword"),
);
} catch (error) {
console.error("Failed to read uploaded file:", error);
}
}
}}
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
/>
<Button
type="button"
variant="outline"
className="w-full justify-start text-left"
>
<span className="truncate">
{field.value instanceof File
? field.value.name
: t("credentials.upload")}
</span>
</Button>
</div>
</FormControl>
</FormItem>
)}
/>
{/* Text Input */}
<Controller
control={form.control}
name="key"
render={({ field }) => (
<FormItem className="flex flex-col">
<FormLabel className="text-xs text-muted-foreground">
{t("hosts.pasteKey")}
</FormLabel>
<FormControl>
<textarea
placeholder={t("placeholders.pastePrivateKey")}
className="flex min-h-[120px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
value={typeof field.value === "string" ? field.value : ""}
onChange={(e) => {
field.onChange(e.target.value);
debouncedKeyDetection(
e.target.value,
form.watch("keyPassword"),
);
}}
/>
</FormControl>
</FormItem>
)}
/>
</div>
{/* Key type detection display */}
{detectedKeyType && (
<div className="text-sm">
<span className="text-muted-foreground">
{t("credentials.detectedKeyType")}:{" "}
</span>
<span
className={`font-medium ${
detectedKeyType === "invalid" || detectedKeyType === "error"
? "text-destructive"
: "text-green-600"
}`}
>
{getFriendlyKeyTypeName(detectedKeyType)}
</span>
{keyDetectionLoading && (
<span className="ml-2 text-muted-foreground">
({t("credentials.detecting")}...)
</span>
)}
</div>
)}
{/* Show existing private key for editing */}
{editingCredential && fullCredentialDetails?.key && (
<FormItem>
<FormLabel>
{t("credentials.sshPrivateKey")} ({t("hosts.existingKey")})
</FormLabel>
<FormControl>
<textarea
readOnly
className="flex min-h-[120px] w-full rounded-md border border-input bg-muted px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
value={fullCredentialDetails.key}
/>
</FormControl>
<div className="text-xs text-muted-foreground mt-1">
{t("credentials.currentKeyContent")}
</div>
{fullCredentialDetails?.detectedKeyType && (
<div className="text-sm mt-2">
<span className="text-muted-foreground">Key type: </span>
<span className="font-medium text-green-600">
{getFriendlyKeyTypeName(fullCredentialDetails.detectedKeyType)}
</span>
</div>
)}
</FormItem>
)}
</div>
{/* Public Key Section */}
<div className="space-y-4">
<FormLabel className="text-sm font-medium">
{t("credentials.sshPublicKey")} ({t("credentials.optional")})
</FormLabel>
<div className="grid grid-cols-2 gap-4">
{/* File Upload */}
<Controller
control={form.control}
name="publicKey"
render={({ field }) => (
<FormItem className="flex flex-col">
<FormLabel className="text-xs text-muted-foreground">
{t("hosts.uploadFile")}
</FormLabel>
<FormControl>
<div className="relative inline-block w-full">
<input
id="public-key-upload"
type="file"
accept="*,.pub,.txt"
onChange={async (e) => {
const file = e.target.files?.[0];
if (file) {
try {
const fileContent = await file.text();
field.onChange(fileContent);
debouncedPublicKeyDetection(fileContent);
} catch (error) {
console.error(
"Failed to read uploaded public key file:",
error,
);
}
}
}}
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
/>
<Button
type="button"
variant="outline"
className="w-full justify-start text-left"
>
<span className="truncate">
{field.value
? t("credentials.publicKeyUploaded")
: t("credentials.uploadPublicKey")}
</span>
</Button>
</div>
</FormControl>
</FormItem>
)}
/>
{/* Text Input */}
<Controller
control={form.control}
name="publicKey"
render={({ field }) => (
<FormItem className="flex flex-col">
<FormLabel className="text-xs text-muted-foreground">
{t("hosts.pasteKey")}
</FormLabel>
<FormControl>
<textarea
placeholder={t("placeholders.pastePublicKey")}
className="flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
value={field.value || ""}
onChange={(e) => {
field.onChange(e.target.value);
debouncedPublicKeyDetection(e.target.value);
}}
/>
</FormControl>
</FormItem>
)}
/>
</div>
{/* Public key type detection */}
{detectedPublicKeyType && form.watch("publicKey") && (
<div className="text-sm">
<span className="text-muted-foreground">
{t("credentials.detectedKeyType")}:{" "}
</span>
<span
className={`font-medium ${
detectedPublicKeyType === "invalid" ||
detectedPublicKeyType === "error"
? "text-destructive"
: "text-green-600"
}`}
>
{getFriendlyKeyTypeName(detectedPublicKeyType)}
</span>
{publicKeyDetectionLoading && (
<span className="ml-2 text-muted-foreground">
({t("credentials.detecting")}...)
</span>
)}
</div>
)}
<div className="text-xs text-muted-foreground">
{t("credentials.publicKeyNote")}
</div>
{/* Show existing public key for editing */}
{editingCredential && fullCredentialDetails?.publicKey && (
<FormItem>
<FormLabel>
{t("credentials.sshPublicKey")} ({t("hosts.existingKey")})
</FormLabel>
<FormControl>
<textarea
readOnly
className="flex min-h-[80px] w-full rounded-md border border-input bg-muted px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
value={fullCredentialDetails.publicKey}
/>
</FormControl>
<div className="text-xs text-muted-foreground mt-1">
{t("credentials.currentPublicKeyContent")}
</div>
</FormItem>
)}
</div>
{/* Generate Public Key Button */}
{form.watch("key") && (
<div className="mt-4">
<Button
type="button"
variant="outline"
size="sm"
onClick={handleGeneratePublicKey}
disabled={generatePublicKeyLoading}
className="w-full"
>
{generatePublicKeyLoading ? (
<>
<span className="mr-2">{t("credentials.generating")}...</span>
</>
) : (
<>
<span>{t("credentials.generatePublicKey")}</span>
</>
)}
</Button>
<p className="text-xs text-muted-foreground mt-2 text-center">
{t("credentials.generatePublicKeyNote")}
</p>
</div>
)}
</div>
</TabsContent>;