游离文件管理与嵌入式容灾元数据

December 20, 2025
4 min read
By devshan

Table of Contents

This is a list of all the sections in this post. Click on any of them to jump to that section.

背景

问题一:游离文件

系统中可能存在两类”游离文件”——S3 中存在但元数据库中没有记录的文件:

类型来源特征
类型 1元数据库损坏/丢失files/*.enc 加密文件
类型 2手动上传到 S3根目录明文文件

问题二:元数据丢失后无法恢复文件名

即使有正确的解密密钥,元数据库丢失后:

  • 加密文件名是 UUID(如 a1b2c3d4.enc
  • 无法知道原始文件名是什么
  • 成千上万文件变成无意义的 UUID

设计决策

游离文件管理

新增设置开关,打开后在根目录显示虚拟的”游离文件”文件夹,复用现有文件列表逻辑。

API 设计

API方法功能
/orphansGET列出所有游离文件
/orphans/adoptPOST收养游离文件(加入元数据)
/orphansDELETE删除游离文件
/orphans/downloadGET下载游离文件

收养逻辑

  • 类型 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 列表
  • currentFiles getter 中添加虚拟”游离文件”文件夹

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.enc

2. 虚拟文件夹需要手动添加

问题:游离文件夹与缩略图文件夹不同,后者在元数据中有记录,前者完全是虚拟的。

解决:在 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 调用)

收益

  1. 数据可恢复性:元数据库丢失后,加密文件仍能恢复有意义的文件名
  2. 灵活管理:手动上传的文件可被系统”收养”并加密保护
  3. 轻量实现:仅存储文件名,移动/复制不需要修改加密文件
  4. 更顺手:游离文件有独立入口,不与正常文件混淆