diff --git a/internal/api/api.go b/internal/api/api.go index 4632b5f..3edb024 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -56,9 +56,6 @@ 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, @@ -240,21 +237,18 @@ func (s *Server) readFile(w http.ResponseWriter, r *http.Request) { w.Write(data) } -// writeFile 以请求体整体作为笔记内容。If-Match: "rev" 要求版本一致;If-None-Match: * 要求文件不存在;都不带则直接覆盖。 -// 带 ?convertFrom=同名笔记 时为加密/取消加密:写入本笔记并永久删除原笔记,If-Match 校验原笔记的版本 +// writeFile 以请求体整体作为笔记内容。If-Match: "rev" 要求版本一致;If-None-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 := 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) + pre := vault.Precondition{ + IfMatch: strings.Trim(r.Header.Get("If-Match"), `"`), + IfNoneMatch: r.Header.Get("If-None-Match") == "*", } + rev, err := s.vault.Write(r.PathValue("path"), data, pre) if err != nil { vaultError(w, r, err) return @@ -263,13 +257,6 @@ 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 { @@ -320,43 +307,6 @@ 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 75682c2..cf9196d 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 || vault.IsEncrypted(p) { + if _, ok := files[p]; !ok { if err := remove(tx, d.id); err != nil { return err } @@ -152,10 +152,6 @@ 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 9067564..e12e267 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,13 +179,3 @@ 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 85b4299..19dc554 100644 --- a/internal/vault/vault.go +++ b/internal/vault/vault.go @@ -35,15 +35,10 @@ type ConflictError struct{ Rev string } func (e *ConflictError) Error() string { return "版本冲突" } const ( - NoteExt = ".md" - // 加密笔记:内容为浏览器端加密的信封,服务端只做不透明存储 - EncryptedNoteExt = ".md.enc" - AssetsExt = ".assets" - // 加密笔记的图片(密文)统一使用此扩展名 - encryptedAssetExt = ".enc" - metaDir = ".rawmark" - keyringFile = "keyring.json" - tmpPrefix = ".rawmark-tmp-" + NoteExt = ".md" + AssetsExt = ".assets" + metaDir = ".rawmark" + tmpPrefix = ".rawmark-tmp-" ) type Vault struct { @@ -110,35 +105,10 @@ func CleanPath(p string) (string, error) { return p, nil } -func IsNote(p string) bool { return noteBase(p) != "" } +func IsNote(p string) bool { return strings.HasSuffix(p, NoteExt) && len(p) > len(NoteExt) } -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 -} +// assetsDir 返回笔记对应的 Typora 风格图片目录:a.md -> a.assets +func assetsDir(note string) string { return strings.TrimSuffix(note, NoteExt) + AssetsExt } func (v *Vault) abs(rel string) (string, error) { rel, err := CleanPath(rel) @@ -214,24 +184,16 @@ 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) - found := err == nil + exists := err == nil if err != nil && !errors.Is(err, fs.ErrNotExist) { return "", err } - if pre.IfNoneMatch && found { + if pre.IfNoneMatch && exists { return "", ErrExists } if pre.IfMatch != "" { - if !found { + if !exists { return "", &ConflictError{} } if Rev(cur) != pre.IfMatch { @@ -360,7 +322,6 @@ func (v *Vault) Tree() ([]Entry, error) { // ---- 移动 / 重命名 ---- // Move 移动或重命名笔记/文件夹。笔记的 .assets 目录随之移动;笔记改名时同步改写正文中指向自身图片目录的链接 -// (加密笔记的正文服务端无法改写,由浏览器解密后处理)。不能借移动在普通笔记与加密笔记之间转换 func (v *Vault) Move(from, to string) error { src, err := v.abs(from) if err != nil { @@ -383,10 +344,10 @@ func (v *Vault) Move(from, to string) error { return err } isNote := !info.IsDir() - if isNote && (!IsNote(from) || !IsNote(to) || IsEncrypted(from) != IsEncrypted(to)) { + if isNote && (!IsNote(from) || !IsNote(to)) { return ErrInvalidPath } - if exists(dst) || (isNote && exists(twin(dst))) { + if _, err := os.Lstat(dst); err == nil { return ErrExists } srcAssets, dstAssets := "", "" @@ -408,9 +369,9 @@ func (v *Vault) Move(from, to string) error { if err := os.Rename(srcAssets, dstAssets); err != nil { return fmt.Errorf("移动图片目录失败: %w", err) } - oldBase := path.Base(noteBase(from)) - newBase := path.Base(noteBase(to)) - if oldBase != newBase && !IsEncrypted(to) { + oldBase := strings.TrimSuffix(path.Base(from), NoteExt) + newBase := strings.TrimSuffix(path.Base(to), NoteExt) + if oldBase != newBase { data, err := os.ReadFile(dst) if err != nil { return err @@ -521,7 +482,7 @@ func (v *Vault) Restore(id string) (TrashItem, error) { } v.mu.Lock() defer v.mu.Unlock() - if exists(dst) || (!item.Dir && exists(twin(dst))) { + if _, err := os.Lstat(dst); err == nil { return TrashItem{}, ErrExists } if item.Assets != "" { @@ -573,9 +534,7 @@ func (v *Vault) SaveAsset(note, origName string, r io.Reader, limit int64) (stri return "", ErrTooLarge } ext := strings.ToLower(filepath.Ext(origName)) - if IsEncrypted(note) { - ext = encryptedAssetExt - } else if !extPattern.MatchString(ext) { + if !extPattern.MatchString(ext) { ext = sniffExt(data) } sum := sha256.Sum256(data) @@ -618,90 +577,3 @@ 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 88b4ba8..ae41a75 100644 --- a/internal/vault/vault_test.go +++ b/internal/vault/vault_test.go @@ -152,108 +152,3 @@ 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) -}