背景
问题一:游离文件
系统中可能存在两类”游离文件”——S3 中存在但元数据库中没有记录的文件:
| 类型 | 来源 | 特征 |
|---|---|---|
| 类型 1 | 元数据库损坏/丢失 | files/*.enc 加密文件 |
| 类型 2 | 手动上传到 S3 | 根目录明文文件 |
问题二:元数据丢失后无法恢复文件名
即使有正确的解密密钥,元数据库丢失后:
- 加密文件名是 UUID(如
a1b2c3d4.enc) - 无法知道原始文件名是什么
- 成千上万文件变成无意义的 UUID
设计决策
游离文件管理
新增设置开关,打开后在根目录显示虚拟的”游离文件”文件夹,复用现有文件列表逻辑。
API 设计:
| API | 方法 | 功能 |
|---|---|---|
/orphans | GET | 列出所有游离文件 |
/orphans/adopt | POST | 收养游离文件(加入元数据) |
/orphans | DELETE | 删除游离文件 |
/orphans/download | GET | 下载游离文件 |
收养逻辑:
- 类型 1(加密文件):直接添加元数据记录
- 类型 2(明文文件):加密 → 上传 → 删除原文件 → 添加元数据
嵌入式容灾元数据
在加密文件尾部嵌入仅文件名,实现轻量级容灾。
文件格式:
[文件头 64B] [加密分块1] [加密分块2] ... [加密的文件名] [长度 4B]设计权衡:
| 方案 | 优点 | 缺点 |
|---|---|---|
| 存储完整元数据(Name, Path, MimeType, Size, CreatedAt…) | 恢复信息全 | 移动/复制都要重写尾部 |
| 仅存储文件名 | 移动/复制无需改动 | 只能恢复文件名 |
选择仅存储文件名:
- 移动操作非常频繁,每次都重写文件不现实
- 文件名是最关键的恢复信息
- MIME 类型可从扩展名推断
- 路径丢失可接受(放根目录即可)
同步策略:
- 上传:写入原始文件名 ✓
- 重命名:同步更新尾部 ✓
- 移动/复制:不修改加密文件 ✓
实现细节
crypto.go 新增
// EmbeddedMetadata 嵌入在加密文件尾部的元数据(仅存储原始文件名)
type EmbeddedMetadata struct {
Name string `json:"name"` // 原始文件名
}
// EncryptStreamWithMetadata 加密并在尾部追加加密的元数据
func (e *Encryptor) EncryptStreamWithMetadata(reader io.Reader, writer io.Writer,
fileKey []byte, originalSize int64, meta *EmbeddedMetadata) error {
// 1. 正常加密文件内容
if err := e.EncryptStream(reader, writer, fileKey, originalSize); err != nil {
return err
}
// 2. 加密元数据
encryptedMeta, _ := e.EncryptMetadata(meta, fileKey)
// 3. 写入加密元数据
writer.Write(encryptedMeta)
// 4. 写入长度(4字节)
binary.Write(writer, binary.BigEndian, uint32(len(encryptedMeta)))
return nil
}
// ExtractEmbeddedMetadata 从文件尾部提取元数据
func (e *Encryptor) ExtractEmbeddedMetadata(data []byte, fileKey []byte) (*EmbeddedMetadata, error) {
// 读取最后4字节获取长度
metaLen := binary.BigEndian.Uint32(data[len(data)-4:])
// 提取并解密元数据
encryptedMeta := data[len(data)-4-int(metaLen) : len(data)-4]
return e.DecryptMetadata(encryptedMeta, fileKey)
}server.go 修改
uploadFile:
embeddedMeta := &crypto.EmbeddedMetadata{Name: header.Filename}
s.encryptor.EncryptStreamWithMetadata(file, &encryptedBuf, fileKey, header.Size, embeddedMeta)renameFile(新增同步逻辑):
if !file.IsDir {
// 下载 → 解密 → 用新文件名重新加密 → 上传覆盖
data, _ := s.s3.DownloadBytes(ctx, s3Key)
fileKey := crypto.DeriveFileKey(s.masterKey, fileID)
s.encryptor.DecryptStream(bytes.NewReader(data), &decryptedBuf, fileKey)
embeddedMeta := &crypto.EmbeddedMetadata{Name: req.Name}
s.encryptor.EncryptStreamWithMetadata(bytes.NewReader(plainData), &encryptedBuf,
fileKey, int64(len(plainData)), embeddedMeta)
s.s3.UploadBytes(ctx, s3Key, encryptedBuf.Bytes(), "application/octet-stream")
}adoptOrphanFile(收养时提取嵌入元数据):
if isEncrypted {
// 尝试从尾部提取文件名
embeddedMeta, _ = s.encryptor.ExtractEmbeddedMetadata(data, fileKey)
if embeddedMeta != nil && embeddedMeta.Name != "" {
fileName = embeddedMeta.Name // 优先使用嵌入的文件名
}
}前端改动
AppState:
- 新增
showOrphanFolder开关 - 新增
orphanFiles列表 currentFilesgetter 中添加虚拟”游离文件”文件夹
files_page:
- 复用现有列表显示逻辑
- 游离文件显示特殊标签:“加密的游离文件” / “明文的游离文件”
- 收养对话框:选择目标路径
settings_page:
- 新增开关:“显示游离文件夹”
踩坑记录
1. Gin 路由参数不能包含 /
问题:
// 错误:/orphans/:key 中 key 不能是 "files/xxx.enc"
api.DELETE("/orphans/:key", s.deleteOrphanFile)解决:改用 query 参数
api.DELETE("/orphans", s.deleteOrphanFile) // ?key=files/xxx.enc2. 虚拟文件夹需要手动添加
问题:游离文件夹与缩略图文件夹不同,后者在元数据中有记录,前者完全是虚拟的。
解决:在 currentFiles getter 中判断并手动插入:
if (showOrphanFolder && currentPath == '/') {
result.insert(0, FileMetadata(
id: 'orphan-folder',
name: '游离文件',
path: '/',
isDir: true,
// ...
));
}3. 明文文件未扫描到
问题:后端跳过了整个 files/ 目录,但使用时可能在 files/ 下放了非 .enc 的明文文件。
解决:
// 只跳过 files/ 下的 .enc 文件(已在元数据检查中处理)
if strings.HasPrefix(obj.Key, "files/") && strings.HasSuffix(obj.Key, ".enc") {
continue
}
// files/ 下的非 .enc 文件作为明文游离文件代码量变化
| 模块 | 变化 |
|---|---|
| crypto.go | +80 行(嵌入元数据加解密) |
| server.go | +200 行(游离文件 API + 重命名同步) |
| app_state.dart | +60 行(状态管理) |
| files_page.dart | +30 行(UI 适配) |
| settings_page.dart | +15 行(开关) |
| api_client.dart | +40 行(API 调用) |
收益
- 数据可恢复性:元数据库丢失后,加密文件仍能恢复有意义的文件名
- 灵活管理:手动上传的文件可被系统”收养”并加密保护
- 轻量实现:仅存储文件名,移动/复制不需要修改加密文件
- 更顺手:游离文件有独立入口,不与正常文件混淆