From 35d3138c7dd2b7140b5ce06e270236554b7f4e1b Mon Sep 17 00:00:00 2001 From: eezd Date: Sat, 17 Oct 2026 14:24:55 +0800 Subject: [PATCH] Add encrypted note storage and keyring endpoints - Treat name.md.enc as encrypted notes; forbid coexisting twins sharing assets - Convert between plain and encrypted notes via PUT ?convertFrom - Store keyring at .rawmark/keyring.json with versioned writes - Allow permanent deletion of single asset files - Exclude encrypted notes from the search index --- internal/api/api.go | 60 +++++++++++- internal/search/search.go | 6 +- internal/search/search_test.go | 28 ++++-- internal/vault/vault.go | 162 +++++++++++++++++++++++++++++---- internal/vault/vault_test.go | 105 +++++++++++++++++++++ 5 files changed, 329 insertions(+), 32 deletions(-) diff --git a/internal/api/api.go b/internal/api/api.go index 3edb024..4632b5f 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -56,6 +56,9 @@ func (s *Server) Handler(static fs.FS) http.Handler { "POST /api/v1/move": s.move, "POST /api/v1/folders": s.mkdir, "POST /api/v1/assets": s.uploadAsset, + "DELETE /api/v1/assets/{path...}": s.deleteAsset, + "GET /api/v1/keyring": s.readKeyring, + "PUT /api/v1/keyring": s.writeKeyring, "GET /api/v1/raw/{path...}": s.raw, "GET /api/v1/trash": s.trashList, "POST /api/v1/trash/{id}/restore": s.trashRestore, @@ -237,18 +240,21 @@ func (s *Server) readFile(w http.ResponseWriter, r *http.Request) { w.Write(data) } -// writeFile 以请求体整体作为笔记内容。If-Match: "rev" 要求版本一致;If-None-Match: * 要求文件不存在;都不带则直接覆盖 +// writeFile 以请求体整体作为笔记内容。If-Match: "rev" 要求版本一致;If-None-Match: * 要求文件不存在;都不带则直接覆盖。 +// 带 ?convertFrom=同名笔记 时为加密/取消加密:写入本笔记并永久删除原笔记,If-Match 校验原笔记的版本 func (s *Server) writeFile(w http.ResponseWriter, r *http.Request) { data, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxNoteBytes)) if err != nil { writeError(w, 413, "笔记过大") return } - pre := vault.Precondition{ - IfMatch: strings.Trim(r.Header.Get("If-Match"), `"`), - IfNoneMatch: r.Header.Get("If-None-Match") == "*", + pre := preconditionOf(r) + var rev string + if from := r.URL.Query().Get("convertFrom"); from != "" { + rev, err = s.vault.Convert(from, r.PathValue("path"), data, pre) + } else { + rev, err = s.vault.Write(r.PathValue("path"), data, pre) } - rev, err := s.vault.Write(r.PathValue("path"), data, pre) if err != nil { vaultError(w, r, err) return @@ -257,6 +263,13 @@ func (s *Server) writeFile(w http.ResponseWriter, r *http.Request) { writeJSON(w, 200, map[string]string{"rev": rev}) } +func preconditionOf(r *http.Request) vault.Precondition { + return vault.Precondition{ + IfMatch: strings.Trim(r.Header.Get("If-Match"), `"`), + IfNoneMatch: r.Header.Get("If-None-Match") == "*", + } +} + func (s *Server) deleteFile(w http.ResponseWriter, r *http.Request) { item, err := s.vault.Delete(r.PathValue("path")) if err != nil { @@ -307,6 +320,43 @@ func (s *Server) uploadAsset(w http.ResponseWriter, r *http.Request) { writeJSON(w, 200, map[string]string{"link": link}) } +func (s *Server) deleteAsset(w http.ResponseWriter, r *http.Request) { + if err := s.vault.DeleteAsset(r.PathValue("path")); err != nil { + vaultError(w, r, err) + return + } + writeJSON(w, 200, map[string]bool{"ok": true}) +} + +// ---- 加密密钥环(内容由浏览器生成,服务端只保存) ---- + +func (s *Server) readKeyring(w http.ResponseWriter, r *http.Request) { + data, rev, err := s.vault.ReadKeyring() + if err != nil { + vaultError(w, r, err) + return + } + w.Header().Set("Content-Type", "application/json") + w.Header().Set("ETag", `"`+rev+`"`) + w.Header().Set("Cache-Control", "no-store") + w.Write(data) +} + +func (s *Server) writeKeyring(w http.ResponseWriter, r *http.Request) { + data, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxJSONBytes)) + if err != nil { + writeError(w, 413, "密钥环过大") + return + } + rev, err := s.vault.WriteKeyring(data, preconditionOf(r)) + if err != nil { + vaultError(w, r, err) + return + } + w.Header().Set("ETag", `"`+rev+`"`) + writeJSON(w, 200, map[string]string{"rev": rev}) +} + // raw 输出仓库内任意非隐藏文件(主要是图片)。以沙箱 CSP 返回,防止 SVG/HTML 中的脚本在本站执行 func (s *Server) raw(w http.ResponseWriter, r *http.Request) { f, info, err := s.vault.OpenRaw(r.PathValue("path")) diff --git a/internal/search/search.go b/internal/search/search.go index cf9196d..75682c2 100644 --- a/internal/search/search.go +++ b/internal/search/search.go @@ -144,7 +144,7 @@ func (x *Index) sync(files map[string]string) error { next[p] = d } for p, d := range x.known { - if _, ok := files[p]; !ok { + if _, ok := files[p]; !ok || vault.IsEncrypted(p) { if err := remove(tx, d.id); err != nil { return err } @@ -152,6 +152,10 @@ func (x *Index) sync(files map[string]string) error { } } for p, rev := range files { + // 加密笔记的内容服务端无法读取,不入索引 + if vault.IsEncrypted(p) { + continue + } old, ok := x.known[p] if ok && old.rev == rev { continue diff --git a/internal/search/search_test.go b/internal/search/search_test.go index e12e267..9067564 100644 --- a/internal/search/search_test.go +++ b/internal/search/search_test.go @@ -92,15 +92,15 @@ func TestSearchTerms(t *testing.T) { f.sync() cases := map[string]string{ - "装饰器": "a.md,目录/c.md", // 3 个字符以上走 FTS - "检索": "b.md,目录/c.md", // 不足 3 个字符走 LIKE - "codemirror": "a.md", // 不区分大小写 - "装饰器 检索": "目录/c.md", // 多个词同时满足 - "目录": "目录/c.md", // 命中路径 - "不存在的词": "", - "100%": "", - `"引号"`: "", - " ": "", + "装饰器": "a.md,目录/c.md", // 3 个字符以上走 FTS + "检索": "b.md,目录/c.md", // 不足 3 个字符走 LIKE + "codemirror": "a.md", // 不区分大小写 + "装饰器 检索": "目录/c.md", // 多个词同时满足 + "目录": "目录/c.md", // 命中路径 + "不存在的词": "", + "100%": "", + `"引号"`: "", + " ": "", } for q, want := range cases { if got := f.paths(q); got != want && !(strings.Contains(want, ",") && sameSet(got, want)) { @@ -179,3 +179,13 @@ func TestSyncTracksChanges(t *testing.T) { t.Errorf("重启后搜索 = %q", got) } } + +func TestEncryptedNotesNotIndexed(t *testing.T) { + f := newFixture(t) + f.write("明文.md", "公开内容") + f.write("秘密.md.enc", `{"data":"公开内容"}`) + f.sync() + if got := f.paths("公开内容"); got != "明文.md" { + t.Errorf("加密笔记不应入索引: %q", got) + } +} diff --git a/internal/vault/vault.go b/internal/vault/vault.go index 19dc554..85b4299 100644 --- a/internal/vault/vault.go +++ b/internal/vault/vault.go @@ -35,10 +35,15 @@ type ConflictError struct{ Rev string } func (e *ConflictError) Error() string { return "版本冲突" } const ( - NoteExt = ".md" - AssetsExt = ".assets" - metaDir = ".rawmark" - tmpPrefix = ".rawmark-tmp-" + NoteExt = ".md" + // 加密笔记:内容为浏览器端加密的信封,服务端只做不透明存储 + EncryptedNoteExt = ".md.enc" + AssetsExt = ".assets" + // 加密笔记的图片(密文)统一使用此扩展名 + encryptedAssetExt = ".enc" + metaDir = ".rawmark" + keyringFile = "keyring.json" + tmpPrefix = ".rawmark-tmp-" ) type Vault struct { @@ -105,10 +110,35 @@ func CleanPath(p string) (string, error) { return p, nil } -func IsNote(p string) bool { return strings.HasSuffix(p, NoteExt) && len(p) > len(NoteExt) } +func IsNote(p string) bool { return noteBase(p) != "" } -// assetsDir 返回笔记对应的 Typora 风格图片目录:a.md -> a.assets -func assetsDir(note string) string { return strings.TrimSuffix(note, NoteExt) + AssetsExt } +func IsEncrypted(p string) bool { return strings.HasSuffix(p, EncryptedNoteExt) } + +// noteBase 去掉笔记扩展名(.md 或 .md.enc);不是笔记时返回空串 +func noteBase(p string) string { + for _, ext := range []string{EncryptedNoteExt, NoteExt} { + if strings.HasSuffix(p, ext) && len(p) > len(ext) && !strings.HasSuffix(p, "/"+ext) { + return strings.TrimSuffix(p, ext) + } + } + return "" +} + +// twin 返回同名的另一种笔记:a.md <-> a.md.enc。二者共用 a.assets,不允许同时存在 +func twin(note string) string { + if IsEncrypted(note) { + return noteBase(note) + NoteExt + } + return noteBase(note) + EncryptedNoteExt +} + +// assetsDir 返回笔记对应的 Typora 风格图片目录:a.md、a.md.enc -> a.assets +func assetsDir(note string) string { return noteBase(note) + AssetsExt } + +func exists(p string) bool { + _, err := os.Lstat(p) + return err == nil +} func (v *Vault) abs(rel string) (string, error) { rel, err := CleanPath(rel) @@ -184,16 +214,24 @@ func (v *Vault) Write(rel string, content []byte, pre Precondition) (string, err } v.mu.Lock() defer v.mu.Unlock() + if !exists(p) && exists(twin(p)) { + return "", ErrExists + } + return writeChecked(p, content, pre) +} + +// writeChecked 按前置条件写入文件,调用方须持有 v.mu +func writeChecked(p string, content []byte, pre Precondition) (string, error) { cur, err := os.ReadFile(p) - exists := err == nil + found := err == nil if err != nil && !errors.Is(err, fs.ErrNotExist) { return "", err } - if pre.IfNoneMatch && exists { + if pre.IfNoneMatch && found { return "", ErrExists } if pre.IfMatch != "" { - if !exists { + if !found { return "", &ConflictError{} } if Rev(cur) != pre.IfMatch { @@ -322,6 +360,7 @@ func (v *Vault) Tree() ([]Entry, error) { // ---- 移动 / 重命名 ---- // Move 移动或重命名笔记/文件夹。笔记的 .assets 目录随之移动;笔记改名时同步改写正文中指向自身图片目录的链接 +// (加密笔记的正文服务端无法改写,由浏览器解密后处理)。不能借移动在普通笔记与加密笔记之间转换 func (v *Vault) Move(from, to string) error { src, err := v.abs(from) if err != nil { @@ -344,10 +383,10 @@ func (v *Vault) Move(from, to string) error { return err } isNote := !info.IsDir() - if isNote && (!IsNote(from) || !IsNote(to)) { + if isNote && (!IsNote(from) || !IsNote(to) || IsEncrypted(from) != IsEncrypted(to)) { return ErrInvalidPath } - if _, err := os.Lstat(dst); err == nil { + if exists(dst) || (isNote && exists(twin(dst))) { return ErrExists } srcAssets, dstAssets := "", "" @@ -369,9 +408,9 @@ func (v *Vault) Move(from, to string) error { if err := os.Rename(srcAssets, dstAssets); err != nil { return fmt.Errorf("移动图片目录失败: %w", err) } - oldBase := strings.TrimSuffix(path.Base(from), NoteExt) - newBase := strings.TrimSuffix(path.Base(to), NoteExt) - if oldBase != newBase { + oldBase := path.Base(noteBase(from)) + newBase := path.Base(noteBase(to)) + if oldBase != newBase && !IsEncrypted(to) { data, err := os.ReadFile(dst) if err != nil { return err @@ -482,7 +521,7 @@ func (v *Vault) Restore(id string) (TrashItem, error) { } v.mu.Lock() defer v.mu.Unlock() - if _, err := os.Lstat(dst); err == nil { + if exists(dst) || (!item.Dir && exists(twin(dst))) { return TrashItem{}, ErrExists } if item.Assets != "" { @@ -534,7 +573,9 @@ func (v *Vault) SaveAsset(note, origName string, r io.Reader, limit int64) (stri return "", ErrTooLarge } ext := strings.ToLower(filepath.Ext(origName)) - if !extPattern.MatchString(ext) { + if IsEncrypted(note) { + ext = encryptedAssetExt + } else if !extPattern.MatchString(ext) { ext = sniffExt(data) } sum := sha256.Sum256(data) @@ -577,3 +618,90 @@ func EscapeLinkPath(s string) string { } var linkEscaper = strings.NewReplacer(" ", "%20", "(", "%28", ")", "%29", "<", "%3C", ">", "%3E") + +// DeleteAsset 永久删除笔记 .assets 目录中的一个文件。用于加密/取消加密笔记后清理转换前的图片: +// 加密前的明文图片不应留在回收站中 +func (v *Vault) DeleteAsset(rel string) error { + if !strings.HasSuffix(path.Dir(rel), AssetsExt) { + return ErrInvalidPath + } + p, err := v.abs(rel) + if err != nil { + return err + } + v.mu.Lock() + defer v.mu.Unlock() + info, err := os.Lstat(p) + if errors.Is(err, fs.ErrNotExist) { + return ErrNotFound + } + if err != nil { + return err + } + if !info.Mode().IsRegular() { + return ErrInvalidPath + } + return os.Remove(p) +} + +// ---- 加密 ---- + +// Convert 在普通笔记与同名加密笔记之间转换:写入 to 并永久删除 from(明文不进回收站)。 +// pre 校验的是 from 的版本,确保转换的是最新内容 +func (v *Vault) Convert(from, to string, content []byte, pre Precondition) (string, error) { + if !IsNote(from) || twin(from) != to { + return "", ErrInvalidPath + } + src, err := v.abs(from) + if err != nil { + return "", err + } + dst, err := v.abs(to) + if err != nil { + return "", err + } + v.mu.Lock() + defer v.mu.Unlock() + cur, err := os.ReadFile(src) + if errors.Is(err, fs.ErrNotExist) { + return "", ErrNotFound + } + if err != nil { + return "", err + } + if pre.IfMatch != "" && Rev(cur) != pre.IfMatch { + return "", &ConflictError{Rev: Rev(cur)} + } + if exists(dst) { + return "", ErrExists + } + if err := atomicWrite(dst, content); err != nil { + return "", err + } + if err := os.Remove(src); err != nil { + return "", err + } + return Rev(content), nil +} + +// ReadKeyring 读取加密密钥环(被加密密码包裹的主密钥,浏览器端生成和解开);尚未设置时返回 ErrNotFound +func (v *Vault) ReadKeyring() ([]byte, string, error) { + data, err := os.ReadFile(filepath.Join(v.root, metaDir, keyringFile)) + if errors.Is(err, fs.ErrNotExist) { + return nil, "", ErrNotFound + } + if err != nil { + return nil, "", err + } + return data, Rev(data), nil +} + +// WriteKeyring 写入密钥环。首次设置用 IfNoneMatch,修改加密密码用 IfMatch,防止覆盖另一端的修改 +func (v *Vault) WriteKeyring(data []byte, pre Precondition) (string, error) { + if !json.Valid(data) { + return "", ErrInvalidPath + } + v.mu.Lock() + defer v.mu.Unlock() + return writeChecked(filepath.Join(v.root, metaDir, keyringFile), data, pre) +} diff --git a/internal/vault/vault_test.go b/internal/vault/vault_test.go index ae41a75..88b4ba8 100644 --- a/internal/vault/vault_test.go +++ b/internal/vault/vault_test.go @@ -152,3 +152,108 @@ func TestTreeHidesMetaAndAssets(t *testing.T) { t.Fatalf("目录树不符: %v", paths) } } + +func TestEncryptedNotes(t *testing.T) { + v := newVault(t) + if _, err := v.Write("a.md", []byte("明文"), Precondition{}); err != nil { + t.Fatal(err) + } + link, err := v.SaveAsset("a.md", "x.png", strings.NewReader("png"), 1<<20) + if err != nil { + t.Fatal(err) + } + // 同名的普通笔记与加密笔记不能同时存在(二者共用 a.assets) + if _, err := v.Write("a.md.enc", []byte("{}"), Precondition{}); !errors.Is(err, ErrExists) { + t.Fatal("同名加密笔记不应能直接创建:", err) + } + // 转换校验原笔记版本,写入新笔记并永久删除原笔记 + if _, err := v.Convert("a.md", "a.md.enc", []byte(`{"v":1}`), Precondition{IfMatch: "stale"}); !isConflict(err) { + t.Fatal("版本不符时应拒绝转换:", err) + } + if _, err := v.Convert("a.md", "b.md.enc", []byte(`{}`), Precondition{}); !errors.Is(err, ErrInvalidPath) { + t.Fatal("只能转换为同名笔记:", err) + } + if _, err := v.Convert("a.md", "a.md.enc", []byte(`{"v":1}`), Precondition{IfMatch: Rev([]byte("明文"))}); err != nil { + t.Fatal(err) + } + if _, _, err := v.Read("a.md"); !errors.Is(err, ErrNotFound) { + t.Fatal("原笔记应被删除") + } + if items, _ := v.TrashList(); len(items) != 0 { + t.Fatal("转换前的明文不应进入回收站") + } + // 加密笔记的图片统一为 .enc,与普通笔记共用图片目录 + encLink, err := v.SaveAsset("a.md.enc", "y.png", strings.NewReader("密文"), 1<<20) + if err != nil || !strings.HasPrefix(encLink, "a.assets/") || !strings.HasSuffix(encLink, ".enc") { + t.Fatal("加密图片链接不符:", encLink, err) + } + // 清理转换前的明文图片:只允许删除 .assets 目录中的文件 + if err := v.DeleteAsset(link); err != nil { + t.Fatal(err) + } + if err := v.DeleteAsset("a.md.enc"); !errors.Is(err, ErrInvalidPath) { + t.Fatal("不应能经此删除笔记") + } + // 改名:图片目录随之移动,密文不改写;不能借移动转换类型,也不能与同名普通笔记冲突 + if err := v.Move("a.md.enc", "c.md"); !errors.Is(err, ErrInvalidPath) { + t.Fatal("移动不能改变加密类型:", err) + } + if _, err := v.Write("c.md", []byte("x"), Precondition{}); err != nil { + t.Fatal(err) + } + if err := v.Move("a.md.enc", "c.md.enc"); !errors.Is(err, ErrExists) { + t.Fatal("不能移动到与普通笔记同名:", err) + } + if err := v.Move("a.md.enc", "d.md.enc"); err != nil { + t.Fatal(err) + } + if data, _, _ := v.Read("d.md.enc"); string(data) != `{"v":1}` { + t.Fatal("加密笔记内容不应被改写:", string(data)) + } + if _, err := os.Stat(filepath.Join(v.Root(), strings.Replace(encLink, "a.assets", "d.assets", 1))); err != nil { + t.Fatal("图片目录未随加密笔记移动:", err) + } + entries, _ := v.Tree() + var names []string + for _, e := range entries { + names = append(names, e.Path) + } + if strings.Join(names, ",") != "c.md,d.md.enc" { + t.Fatal("目录树应包含加密笔记:", names) + } +} + +func TestKeyring(t *testing.T) { + v := newVault(t) + if _, _, err := v.ReadKeyring(); !errors.Is(err, ErrNotFound) { + t.Fatal("尚未设置时应返回 ErrNotFound") + } + if _, err := v.WriteKeyring([]byte("not json"), Precondition{IfNoneMatch: true}); !errors.Is(err, ErrInvalidPath) { + t.Fatal("应拒绝非 JSON") + } + rev, err := v.WriteKeyring([]byte(`{"v":1}`), Precondition{IfNoneMatch: true}) + if err != nil { + t.Fatal(err) + } + if _, err := v.WriteKeyring([]byte(`{"v":2}`), Precondition{IfNoneMatch: true}); !errors.Is(err, ErrExists) { + t.Fatal("已设置时不能再次初始化") + } + if _, err := v.WriteKeyring([]byte(`{"v":2}`), Precondition{IfMatch: "stale"}); !isConflict(err) { + t.Fatal("版本不符时应拒绝") + } + if _, err := v.WriteKeyring([]byte(`{"v":2}`), Precondition{IfMatch: rev}); err != nil { + t.Fatal(err) + } + if data, _, _ := v.ReadKeyring(); string(data) != `{"v":2}` { + t.Fatal("密钥环内容不符:", string(data)) + } + // 密钥环在隐藏目录中,不经文件接口暴露 + if _, _, err := v.Read(".rawmark/keyring.json"); err == nil { + t.Fatal("不应能经文件接口读取密钥环") + } +} + +func isConflict(err error) bool { + var c *ConflictError + return errors.As(err, &c) +}