From 08f4e4aa7de9ef0ca99f78ec721c0391c9351ab2 Mon Sep 17 00:00:00 2001 From: eezd Date: Sat, 24 Oct 2026 18:12:23 +0800 Subject: [PATCH] feat(assets): store note images in shared sibling assets directories --- README.md | 4 +- internal/api/api.go | 2 +- internal/api/images_test.go | 32 +++++ internal/vault/cleanup_test.go | 8 +- internal/vault/images.go | 26 +++- internal/vault/images_test.go | 4 +- internal/vault/import.go | 29 +++- internal/vault/import_links.go | 19 ++- internal/vault/import_test.go | 17 ++- internal/vault/metadata_test.go | 6 +- internal/vault/shared_images.go | 96 +++++++++++++ internal/vault/shared_images_test.go | 197 +++++++++++++++++++++++++++ internal/vault/vault.go | 101 +++++++++++--- internal/vault/vault_test.go | 41 +++--- 14 files changed, 507 insertions(+), 75 deletions(-) create mode 100644 internal/vault/shared_images.go create mode 100644 internal/vault/shared_images_test.go diff --git a/README.md b/README.md index 6f66cc9..eea6974 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ docker run -d --name rawmark \ ``` - 首次打开直接设置登录密码,之后凭密码登录;请在将新实例开放给他人访问前完成初始化。 -- 笔记以普通 `.md` 文件保存在 `数据目录/vault/`,图片按 Typora 习惯存放在同级的 `笔记名.assets/`,可直接用 Typora 等编辑器打开;外部修改会实时同步到网页。 +- 笔记以普通 `.md` 文件保存在 `数据目录/vault/`,粘贴、上传和导入的图片统一存放在笔记同级的 `assets/`,链接使用 `./assets/文件名`,同目录相同内容去重。笔记跨目录移动时图片跟随,仍被其他笔记引用的原图保留;删除单篇笔记不会删除共享图片。旧 `笔记名.assets/` 仍可读取、移动与恢复;外部修改会实时同步到网页。 - 知识工具:标准 Markdown 链接构成图谱,支持正文 `#标签` 与 YAML `tags`。笔记或文件夹在应用内改名、移动时维护入链和相对出链;公式和代码示例不参与关系提取。 - 日记与模板:默认日记路径为 `Daily/YYYY-MM-DD.md`(本地日期),已有日记直接打开;模板为 `Templates/` 下的普通 Markdown,支持 `{{date}}`、`{{time}}`、`{{title}}`,资源链接按新笔记位置调整。可在设置中修改目录和日记模板。 - 外观:浅色玻璃工作台与深色模式,字体资源随应用本地提供;界面字号默认 14px,可输入 14–20px 并单独重置,与编辑器及阅读字号独立;排版、玻璃透明度、选中文本工具栏均可在设置中调整并即时保存。 @@ -43,7 +43,7 @@ docker run -d --name rawmark \ - 设置:桌面使用居中窗口,手机使用分类与内容两级布局;支持搜索设置并定位、常用排版项单独重置,自动保存状态与失败提示可见。外观、编辑器和日记与模板分别管理,安装入口位于「关于」。 - 快捷键:支持按命令名称、ID 或组合键搜索,添加、逐条移除和单项/全部重置,并拒绝冲突绑定。默认 Ctrl+Alt+1~6 设置对应级别标题,同级再次触发恢复正文;字号可直接输入。 - 文件目录操作:右键「文件目录」标题或目录空白处,可搜索、打开标签索引、新建笔记、新建目录、导入文档及管理图片;也可点击标题打开菜单。文件夹右键操作以所选文件夹为目标。 -- 导入:文件目录或文件夹右键菜单选择「导入」,支持多选 UTF-8 `.md` 文件或单个 ZIP(总上传上限 100 MB)。ZIP 新建同名文件夹并保留层级,本地图片转换为笔记独立附件,原附件保留;同名内容不覆盖,缺失的本地链接会提示,外部图片不会自动下载。 +- 导入:文件目录或文件夹右键菜单选择「导入」,支持多选 UTF-8 `.md` 文件或单个 ZIP(总上传上限 100 MB)。ZIP 新建同名文件夹并保留笔记层级,本地图片归入各笔记同级 `assets/`;全部改链后移除已迁移且无引用的重复原图,其他附件和未使用图片保留。同名不同内容不覆盖,缺失的本地链接会提示,外部图片不会自动下载。 - 自动化:「API 令牌」中创建只读或读写令牌,以 `Authorization: Bearer <令牌>` 调用 REST API,接口说明见 `/api/v1/openapi.json`。 | 环境变量 | 默认值 | 说明 | diff --git a/internal/api/api.go b/internal/api/api.go index 22d9719..c4df570 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -371,7 +371,7 @@ func (s *Server) mkdir(w http.ResponseWriter, r *http.Request) { writeJSON(w, 200, map[string]bool{"ok": true}) } -// uploadAsset 接收 multipart 表单中的 file 字段,存入 ?note= 指定笔记的 .assets 目录 +// uploadAsset 接收 multipart 表单中的 file 字段,存入指定笔记或目录下的共享 assets 目录。 func (s *Server) uploadAsset(w http.ResponseWriter, r *http.Request) { r.Body = http.MaxBytesReader(w, r.Body, maxAssetBytes+1<<20) file, header, err := r.FormFile("file") diff --git a/internal/api/images_test.go b/internal/api/images_test.go index 871567e..8788d2e 100644 --- a/internal/api/images_test.go +++ b/internal/api/images_test.go @@ -5,9 +5,41 @@ import ( "io" "mime/multipart" "net/http" + "strings" "testing" ) +func TestUploadsReturnSharedRelativeLinks(t *testing.T) { + f := newFixture(t) + f.write("notes/a.md", "正文") + f.write("notes/b.md", "正文") + var body bytes.Buffer + form := multipart.NewWriter(&body) + file, err := form.CreateFormFile("file", "image.png") + if err != nil { + t.Fatal(err) + } + if _, err := io.WriteString(file, "image"); err != nil { + t.Fatal(err) + } + if err := form.Close(); err != nil { + t.Fatal(err) + } + var previous string + for _, query := range []string{"note=notes/a.md", "note=notes/b.md", "folder=notes"} { + result := decode[struct{ Link string }](t, f.do(f.admin, "POST", "/api/v1/assets?"+query, body.String(), map[string]string{"Content-Type": form.FormDataContentType()})) + if !strings.HasPrefix(result.Link, "./assets/") || (previous != "" && previous != result.Link) { + t.Fatalf("上传链接或共享去重不符:%s", result.Link) + } + previous = result.Link + res := f.do(f.admin, "GET", "/api/v1/raw/notes/"+strings.TrimPrefix(result.Link, "./"), "", nil) + data, err := io.ReadAll(res.Body) + if res.StatusCode != 200 || err != nil || string(data) != "image" { + t.Fatalf("返回链接不能读取原图:%d %q %v", res.StatusCode, data, err) + } + } +} + func TestReplaceImageAPI(t *testing.T) { f := newFixture(t) f.write("assets/a.png", "original") diff --git a/internal/vault/cleanup_test.go b/internal/vault/cleanup_test.go index 9058194..99ff2a1 100644 --- a/internal/vault/cleanup_test.go +++ b/internal/vault/cleanup_test.go @@ -48,7 +48,7 @@ func TestFolderUploadReusesDeduplication(t *testing.T) { t.Fatal(err) } second, err := v.SaveFolderAsset("", "b.png", strings.NewReader("image"), 10) - if err != nil || first != second || !strings.HasPrefix(first, "assets/") { + if err != nil || first != second || !strings.HasPrefix(first, "./assets/") { t.Fatalf("文件夹上传未去重:%s %s %v", first, second, err) } if _, err := v.SaveFolderAsset("missing", "a.png", strings.NewReader("image"), 10); !errors.Is(err, ErrNotFound) { @@ -72,14 +72,14 @@ func TestCleanupProtectsLinkedImages(t *testing.T) { t.Fatal(err) } metadata := Analyze("a.md", []byte(content)) - if len(metadata.Assets) != 1 || metadata.Assets[0].Target != link { + if len(metadata.Assets) != 1 || metadata.Assets[0].Target != strings.TrimPrefix(link, "./") { t.Fatalf("链接内图片未记录引用:%+v", metadata) } - result, err := v.CleanupImages([]string{link}) + result, err := v.CleanupImages([]string{strings.TrimPrefix(link, "./")}) if err != nil || len(result.Deleted) != 0 || len(result.Skipped) != 1 || result.Skipped[0].Reason != "仍被笔记引用" { t.Fatalf("链接内图片未受保护:%+v %v", result, err) } - if _, err := v.Stat(link); err != nil { + if _, err := v.Stat(strings.TrimPrefix(link, "./")); err != nil { t.Fatal("正文引用的图片不应被移走", err) } }) diff --git a/internal/vault/images.go b/internal/vault/images.go index 1c30f16..0c41323 100644 --- a/internal/vault/images.go +++ b/internal/vault/images.go @@ -53,7 +53,7 @@ type linkChange struct { } // 调用方持有写锁;先计算全部改写,移动成功后再写入,出错时保留回滚依据。 -func (v *Vault) moveLinkChanges(from, to string, directory bool, ownAssets bool) ([]linkChange, error) { +func (v *Vault) moveLinkChanges(from, to string, directory bool, ownAssets bool, images map[string]string) ([]linkChange, error) { entries, err := v.Tree() if err != nil { return nil, err @@ -68,7 +68,18 @@ func (v *Vault) moveLinkChanges(from, to string, directory bool, ownAssets bool) return nil, err } next := movedPath(entry.Path, from, to, directory) - after, err := rewriteMovedLinks(before, entry.Path, next, from, to, directory, ownAssets) + after, err := rewriteLocalLinks(before, entry.Path, next, func(target string) string { + if entry.Path == from { + if image, ok := images[target]; ok { + return image + } + } + mapped := movedPath(target, from, to, directory) + if ownAssets { + mapped = movedPath(mapped, AssetsDir(from), AssetsDir(to), true) + } + return mapped + }) if err != nil { return nil, err } @@ -92,7 +103,8 @@ func movedPath(p, from, to string, directory bool) string { return p } -func rewriteMovedLinks(content []byte, oldNote, newNote, from, to string, directory, ownAssets bool) ([]byte, error) { +// 只替换链接地址,不重新序列化正文;移动和图片归位共用相同的代码/公式边界。 +func rewriteLocalLinks(content []byte, oldNote, newNote string, mapTarget func(string) string) ([]byte, error) { var out []byte last := 0 excluded := nonProseRanges(parseMetadata(content)) @@ -114,10 +126,7 @@ func rewriteMovedLinks(content []byte, oldNote, newNote, from, to string, direct if !ok { continue } - mapped := movedPath(target, from, to, directory) - if ownAssets { - mapped = movedPath(mapped, AssetsDir(from), AssetsDir(to), true) - } + mapped := mapTarget(target) if mapped == target && path.Dir(oldNote) == path.Dir(newNote) { continue } @@ -126,6 +135,9 @@ func rewriteMovedLinks(content []byte, oldNote, newNote, from, to string, direct return nil, err } replacement := EscapeLinkPath(filepath.ToSlash(rel)) + if strings.HasPrefix(replacement, "assets/") { + replacement = "./" + replacement + } if u.RawQuery != "" { replacement += "?" + u.RawQuery } diff --git a/internal/vault/images_test.go b/internal/vault/images_test.go index e9cff45..91ea4ff 100644 --- a/internal/vault/images_test.go +++ b/internal/vault/images_test.go @@ -82,6 +82,7 @@ func TestReplaceImagePreservesPathAndUploadContents(t *testing.T) { if err != nil { t.Fatal(err) } + link = strings.TrimPrefix(link, "./") if err := v.ReplaceImage(link, "replacement.PNG", strings.NewReader("replacement"), 100); err != nil { t.Fatal(err) } @@ -89,8 +90,9 @@ func TestReplaceImagePreservesPathAndUploadContents(t *testing.T) { if err != nil { t.Fatal(err) } + newLink = strings.TrimPrefix(newLink, "./") again, err := v.SaveAsset("a.md", "old.png", strings.NewReader("original"), 100) - if err != nil || again != newLink || newLink == link { + if err != nil || strings.TrimPrefix(again, "./") != newLink || newLink == link { t.Fatalf("替换后的去重不符:%q %q %q %v", link, newLink, again, err) } for name, want := range map[string]string{link: "replacement", newLink: "original"} { diff --git a/internal/vault/import.go b/internal/vault/import.go index ce54e08..e7a7e86 100644 --- a/internal/vault/import.go +++ b/internal/vault/import.go @@ -169,6 +169,7 @@ func (v *Vault) Import(ctx context.Context, dir, folder string, files []ImportFi return result, fmt.Errorf("%w:没有可导入的 Markdown 笔记", ErrInvalidImport) } sort.Strings(notes) + migrated := map[string]bool{} for _, note := range notes { if err := ctx.Err(); err != nil { return result, err @@ -186,7 +187,7 @@ func (v *Vault) Import(ctx context.Context, dir, folder string, files []ImportFi return result, fmt.Errorf("%w:Markdown 必须是 UTF-8 文本", ErrInvalidImport) } content = bytes.TrimPrefix(content, []byte{0xef, 0xbb, 0xbf}) - updated, warnings, err := staged.importLinks(ctx, note, content, &total) + updated, warnings, err := staged.importLinks(ctx, note, content, &total, migrated) if err != nil { return result, err } @@ -199,8 +200,32 @@ func (v *Vault) Import(ctx context.Context, dir, folder string, files []ImportFi return result, err } } + // 所有笔记改链完成后再清理已迁移的原图,避免后处理的笔记读取不到共享来源。 + if len(migrated) > 0 { + used := map[string]bool{} + for _, note := range notes { + body, _, err := staged.Read(note) + if err != nil { + return result, err + } + metadata := Analyze(note, body) + if len(metadata.Warnings) > 0 { + return result, fmt.Errorf("%w:%s 的元数据异常,无法确认图片引用", ErrInvalidImport, note) + } + for _, ref := range metadata.Assets { + used[ref.Target] = true + } + } + for original := range migrated { + if !used[original] { + if err := os.Remove(filepath.Join(stage, filepath.FromSlash(original))); err != nil { + return result, err + } + } + } + } result.Notes = len(notes) - // 附件数量包含原始附件及为各笔记生成的独立副本。 + // 附件数量为最终保留的文件,包含目录共享图片与其他原始附件。 count := 0 err = filepath.WalkDir(stage, func(p string, d fs.DirEntry, err error) error { if err != nil { diff --git a/internal/vault/import_links.go b/internal/vault/import_links.go index 0bc8058..b91d0bc 100644 --- a/internal/vault/import_links.go +++ b/internal/vault/import_links.go @@ -3,7 +3,6 @@ package vault import ( "bytes" "context" - "fmt" "html" "net/url" "os" @@ -229,7 +228,7 @@ func importLinkSpans(content []byte) []importLink { return spans } -func (v *Vault) importLinks(ctx context.Context, note string, content []byte, total *int64) ([]byte, []string, error) { +func (v *Vault) importLinks(ctx context.Context, note string, content []byte, total *int64, migrated map[string]bool) ([]byte, []string, error) { missing := map[string]bool{} replacements := map[string]string{} spans := importLinkSpans(content) @@ -279,15 +278,12 @@ func (v *Vault) importLinks(ctx context.Context, note string, content []byte, to return nil, nil, err } // 只统计新生成的副本;已有同名附件必须与哈希对应的内容一致。 - dest := filepath.Join(v.root, filepath.FromSlash(AssetsDir(note)), assetName(data, path.Base(rel))) - existing, readErr := os.ReadFile(dest) - if readErr != nil && !os.IsNotExist(readErr) { - return nil, nil, readErr + dir := path.Join(path.Dir(note), "assets") + dest, exists, err := v.assetDestination(dir, assetName(data, path.Base(rel)), data) + if err != nil { + return nil, nil, err } - if readErr == nil && !bytes.Equal(data, existing) { - return nil, nil, fmt.Errorf("%w:附件名称与内容冲突", ErrInvalidImport) - } - if os.IsNotExist(readErr) { + if !exists { if int64(len(data)) > maxImportExpanded-*total { return nil, nil, ErrTooLarge } @@ -297,6 +293,9 @@ func (v *Vault) importLinks(ctx context.Context, note string, content []byte, to if err != nil { return nil, nil, err } + if dest != rel { + migrated[rel] = true + } replacements[rel] = replacement } if u.RawQuery != "" { diff --git a/internal/vault/import_test.go b/internal/vault/import_test.go index 3906e08..c0dba2b 100644 --- a/internal/vault/import_test.go +++ b/internal/vault/import_test.go @@ -53,14 +53,14 @@ func TestImportZipAssetsAndMove(t *testing.T) { if err != nil { t.Fatal(err) } - if result.Notes != 2 || result.Assets != 3 || len(result.Warnings) != 1 || len(result.Paths) != 1 || result.Paths[0] != "知识库" { + if result.Notes != 2 || result.Assets != 2 || len(result.Warnings) != 1 || len(result.Paths) != 1 || result.Paths[0] != "知识库" { t.Fatalf("结果不符: %+v", result) } body, _, err := v.Read("知识库/目录/笔记.md") if err != nil { t.Fatal(err) } - link := EscapeLinkPath("笔记.assets") + "/" + assetName([]byte("image"), "x.png") + link := "./assets/" + assetName([]byte("image"), "x.png") expected := strings.Replace(content, "", "<"+link+">", 1) expected = strings.Replace(expected, "[图]: assets/图%20(1).png", "[图]: "+link, 1) expected = strings.Replace(expected, "src=\"assets/图%20(1).png\"", "src=\""+link+"\"", 1) @@ -77,15 +77,14 @@ func TestImportZipAssetsAndMove(t *testing.T) { if err != nil { t.Fatal(err) } - if !strings.Contains(string(moved), "改名.assets/") { + if !strings.Contains(string(moved), "./assets/") { t.Fatal("图片链接未跟随改名") } - if _, err := os.Stat(filepath.Join(v.root, "移动目标/改名.assets", assetName([]byte("image"), "x.png"))); err != nil { + if _, err := os.Stat(filepath.Join(v.root, "移动目标/assets", assetName([]byte("image"), "x.png"))); err != nil { t.Fatal("附件未跟随", err) } - original, err := os.ReadFile(filepath.Join(v.root, "知识库/目录/assets/图 (1).png")) - if err != nil || string(original) != "image" { - t.Fatal("原始附件丢失", err) + if _, err := os.Stat(filepath.Join(v.root, "知识库/目录/assets/图 (1).png")); !os.IsNotExist(err) { + t.Fatal("已迁移的重复原图应移除", err) } } @@ -101,14 +100,14 @@ func TestImportPreservesContainersAndOnlyRewritesActualLinks(t *testing.T) { if err != nil { t.Fatal(err) } - if result.Notes != 1 || result.Assets != 3 || len(result.Warnings) != 0 { + if result.Notes != 1 || result.Assets != 2 || len(result.Warnings) != 0 { t.Fatalf("仅真实图片应生成附件副本,脚注正文不应产生缺失警告:%+v", result) } body, _, err := v.Read("导入/文章.md") if err != nil { t.Fatal(err) } - link := EscapeLinkPath("文章.assets") + "/" + assetName([]byte("image"), "x.png") + link := "./assets/" + assetName([]byte("image"), "x.png") want := strings.Replace(content, "[pic]: assets/image.png", "[pic]: "+link, 1) want = strings.Replace(want, "![跨行](\nassets/image.png", "![跨行](\n"+link, 1) if string(body) != want { diff --git a/internal/vault/metadata_test.go b/internal/vault/metadata_test.go index dc2d3e0..0a7ca26 100644 --- a/internal/vault/metadata_test.go +++ b/internal/vault/metadata_test.go @@ -38,9 +38,7 @@ func TestMoveMaintainsIncomingAndOutgoingLinks(t *testing.T) { t.Fatal(err) } } - if _, err := v.SaveAsset("dir/a.md", "p.png", strings.NewReader("image"), 100); err != nil { - t.Fatal(err) - } + legacyAsset(t, v, "dir/a.md", "p.png", "image") if _, err := v.Move("dir/a.md", "new/renamed.md"); err != nil { t.Fatal(err) } @@ -67,7 +65,7 @@ func TestMetadataSkipsMathAndResolvesFootnotes(t *testing.T) { if !reflect.DeepEqual(m.Tags, []string{"正文"}) || len(m.Links) != 0 || len(m.Assets) != 1 || m.Assets[0].Target != "assets/foot.png" { t.Fatalf("扩展语法误识别:%+v", m) } - rewritten, err := rewriteMovedLinks([]byte(content), "a.md", "dir/a.md", "a.md", "dir/a.md", false, false) + rewritten, err := rewriteLocalLinks([]byte(content), "a.md", "dir/a.md", func(target string) string { return target }) if err != nil || !strings.Contains(string(rewritten), "[fake](bad.md)") || !strings.Contains(string(rewritten), "[fake](bad2.md)") || !strings.Contains(string(rewritten), "../assets/foot.png") { t.Fatalf("公式或脚注改写不符:%s %v", rewritten, err) } diff --git a/internal/vault/shared_images.go b/internal/vault/shared_images.go new file mode 100644 index 0000000..2fac2cc --- /dev/null +++ b/internal/vault/shared_images.go @@ -0,0 +1,96 @@ +package vault + +import ( + "fmt" + "os" + "path" + "path/filepath" + "strings" +) + +type imageTransfer struct { + source, target string + data []byte + created bool + removeSource bool + removed bool +} + +// 调用方持有写锁,并在后续笔记移动失败时撤回新副本。 +// 旧的独立附件仍由 Move 连同目录移动;共享图片只在其他笔记不再引用时移走原件。 +func (v *Vault) copyNoteImages(from, to string) (transfers []imageTransfer, targets map[string]string, err error) { + content, _, err := v.Read(from) + if err != nil { + return nil, nil, err + } + metadata := Analyze(from, content) + if len(metadata.Warnings) != 0 { + return nil, nil, fmt.Errorf("%w:%s 的元数据异常,无法确认图片引用", ErrInvalidPath, from) + } + entries, err := v.Tree() + if err != nil { + return nil, nil, err + } + used := map[string]bool{} + for _, entry := range entries { + if entry.Dir || !IsNote(entry.Path) || entry.Path == from { + continue + } + body, _, e := v.Read(entry.Path) + if e != nil { + return nil, nil, e + } + m := Analyze(entry.Path, body) + if len(m.Warnings) != 0 { + return nil, nil, fmt.Errorf("%w:%s 的元数据异常,无法确认图片引用", ErrInvalidPath, entry.Path) + } + for _, ref := range m.Assets { + used[ref.Target] = true + } + } + targets = map[string]string{} + for _, ref := range metadata.Assets { + if !IsImage(ref.Target) || strings.HasPrefix(ref.Target, AssetsDir(from)+"/") { + continue + } + if _, seen := targets[ref.Target]; seen { + continue + } + source, e := v.abs(ref.Target) + if e != nil { + return transfers, targets, e + } + info, e := os.Lstat(source) + if os.IsNotExist(e) { + continue // 已缺失的图片保留链接,由缺失检查报告。 + } + if e != nil { + return transfers, targets, e + } + if !info.Mode().IsRegular() { + return transfers, targets, ErrInvalidPath + } + data, e := os.ReadFile(source) + if e != nil { + return transfers, targets, e + } + dest, exists, e := v.assetDestination(path.Join(path.Dir(to), "assets"), path.Base(ref.Target), data) + if e != nil { + return transfers, targets, e + } + targets[ref.Target] = dest + transfer := imageTransfer{source: ref.Target, target: dest, data: data, removeSource: !used[ref.Target] && ref.Target != dest} + if !exists { + full := filepath.Join(v.root, filepath.FromSlash(dest)) + if e := os.MkdirAll(filepath.Dir(full), 0o755); e != nil { + return transfers, targets, e + } + if e := atomicWrite(full, data); e != nil { + return transfers, targets, e + } + transfer.created = true + } + transfers = append(transfers, transfer) + } + return transfers, targets, nil +} diff --git a/internal/vault/shared_images_test.go b/internal/vault/shared_images_test.go new file mode 100644 index 0000000..ad18ac5 --- /dev/null +++ b/internal/vault/shared_images_test.go @@ -0,0 +1,197 @@ +package vault + +import ( + "context" + "errors" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestNoteUploadsShareFolderAssetsAndRejectInvalidNotes(t *testing.T) { + v := newVault(t) + a, err := v.SaveAsset("dir/a.md", "x.png", strings.NewReader("image"), 100) + if err != nil { + t.Fatal(err) + } + b, err := v.SaveAsset("dir/b.md", "x.png", strings.NewReader("image"), 100) + if err != nil || a != b || !strings.HasPrefix(a, "./assets/") { + t.Fatalf("同目录上传应共享资源:%s %s %v", a, b, err) + } + for _, note := range []string{"../x.md", "folder/../x.md", "/x.md", ".hidden/x.md", "a.txt"} { + if _, err := v.SaveAsset(note, "x.png", strings.NewReader("image"), 100); !errors.Is(err, ErrInvalidPath) { + t.Errorf("非法笔记路径 %s:%v", note, err) + } + } +} + +func TestNoteMoveTransfersSharedImagesWithoutBreakingOtherNotes(t *testing.T) { + for _, shared := range []bool{false, true} { + for _, collision := range []string{"none", "same", "different"} { + t.Run(collision+map[bool]string{false: "-exclusive", true: "-shared"}[shared], func(t *testing.T) { + v := newVault(t) + link, err := v.SaveAsset("old/a.md", "a.png", strings.NewReader("image"), 100) + if err != nil { + t.Fatal(err) + } + file := filepath.Base(link) + body := "![图](" + link + "?q=1#part \"标题\")\n[图]: " + link + "\n\n`![](" + link + ")`\n\n$$\n![](" + link + ")\n$$\n" + if _, err := v.Write("old/a.md", []byte(body), Precondition{}); err != nil { + t.Fatal(err) + } + if shared { + if _, err := v.Write("else/b.md", []byte("[图片](../old/assets/"+file+")"), Precondition{}); err != nil { + t.Fatal(err) + } + } + if collision != "none" { + if err := v.Mkdir("new/assets"); err != nil { + t.Fatal(err) + } + data := "image" + if collision == "different" { + data = "unrelated" + } + if err := os.WriteFile(filepath.Join(v.Root(), "new/assets", file), []byte(data), 0644); err != nil { + t.Fatal(err) + } + } + if _, err := v.Move("old/a.md", "new/renamed.md"); err != nil { + t.Fatal(err) + } + got, _, err := v.Read("new/renamed.md") + if err != nil { + t.Fatal(err) + } + metadata := Analyze("new/renamed.md", got) + for _, ref := range metadata.Assets { + if filepath.ToSlash(filepath.Dir(ref.Target)) != "new/assets" { + t.Fatalf("图片未归入目标目录:%s", got) + } + data, err := os.ReadFile(filepath.Join(v.Root(), ref.Target)) + if err != nil || string(data) != "image" { + t.Fatalf("图片丢失或被覆盖:%q %v", data, err) + } + } + if !strings.Contains(string(got), "?q=1#part \"标题\"") || !strings.Contains(string(got), "`![]("+link+")`") || !strings.Contains(string(got), "$$\n![]("+link+")") { + t.Fatalf("正文中的标题、代码或公式被改写:%s", got) + } + _, err = os.Stat(filepath.Join(v.Root(), "old/assets", file)) + if (shared && err != nil) || (!shared && !os.IsNotExist(err)) { + t.Fatalf("源图保留状态错误 shared=%v:%v", shared, err) + } + if collision == "different" { + data, _ := os.ReadFile(filepath.Join(v.Root(), "new/assets", file)) + if string(data) != "unrelated" { + t.Fatal("目标已有图片被覆盖") + } + } + }) + } + } +} + +func TestNoteDeleteAndRenamePreserveSharedAssets(t *testing.T) { + v := newVault(t) + link, err := v.SaveAsset("a.md", "a.png", strings.NewReader("image"), 100) + if err != nil { + t.Fatal(err) + } + for _, note := range []string{"a.md", "b.md"} { + if _, err := v.Write(note, []byte("![]("+link+")"), Precondition{}); err != nil { + t.Fatal(err) + } + } + if rewritten, err := v.Move("a.md", "renamed.md"); err != nil || rewritten { + t.Fatalf("同目录改名无需改写图片:%v %v", rewritten, err) + } + item, err := v.Delete("renamed.md") + if err != nil || item.Assets != "" { + t.Fatalf("删除笔记不得带走共享目录:%+v %v", item, err) + } + if _, err := v.Stat(strings.TrimPrefix(link, "./")); err != nil { + t.Fatal("共享图片丢失", err) + } + if _, err := v.Restore(item.ID); err != nil { + t.Fatal(err) + } +} + +func TestFailedNoteImageTransferRemovesOnlyCreatedCopies(t *testing.T) { + v := newVault(t) + if err := v.Mkdir("old/assets"); err != nil { + t.Fatal(err) + } + for _, name := range []string{"first.png", "second.png"} { + if err := os.WriteFile(filepath.Join(v.Root(), "old/assets", name), []byte(name), 0644); err != nil { + t.Fatal(err) + } + } + body := "![](assets/first.png) ![](assets/second.png)" + if _, err := v.Write("old/a.md", []byte(body), Precondition{}); err != nil { + t.Fatal(err) + } + if err := v.Mkdir("new/assets"); err != nil { + t.Fatal(err) + } + outside := filepath.Join(t.TempDir(), "outside.png") + if err := os.WriteFile(outside, []byte("private"), 0600); err != nil { + t.Fatal(err) + } + if err := os.Symlink(outside, filepath.Join(v.Root(), "new/assets/second.png")); err != nil { + t.Fatal(err) + } + if _, err := v.Move("old/a.md", "new/a.md"); err == nil { + t.Fatal("越界链接应使移动失败") + } + got, _, err := v.Read("old/a.md") + if err != nil || string(got) != body { + t.Fatalf("失败后原文丢失:%s %v", got, err) + } + if _, err := os.Stat(filepath.Join(v.Root(), "new/assets/first.png")); !os.IsNotExist(err) { + t.Fatal("已创建副本没有撤回", err) + } + for _, name := range []string{"first.png", "second.png"} { + if _, err := v.Stat("old/assets/" + name); err != nil { + t.Fatal("源图不应被移除", err) + } + } +} + +func TestImportSharesImagesAndRemovesOnlyMigratedOriginals(t *testing.T) { + v := newVault(t) + source := importZip(t, + zipEntry{name: "a.md", content: "![](original/p.png)"}, + zipEntry{name: "b.md", content: "[图片](original/p.png)"}, + zipEntry{name: "sub/c.md", content: ""}, + zipEntry{name: "original/p.png", content: "shared"}, + zipEntry{name: "original/unused.png", content: "unused"}, + zipEntry{name: "original/document.pdf", content: "attachment"}, + ) + result, err := v.Import(context.Background(), "", "imported", []ImportFile{source}) + if err != nil || result.Assets != 4 { + t.Fatalf("共享图片与其他附件数量不符:%+v %v", result, err) + } + if _, err := v.Stat("imported/original/p.png"); !errors.Is(err, ErrNotFound) { + t.Fatal("重复原图未清理", err) + } + for _, original := range []string{"unused.png", "document.pdf"} { + if _, err := v.Stat("imported/original/" + original); err != nil { + t.Fatal("不应清理其他原始附件", err) + } + } + for _, note := range []string{"a.md", "b.md", "sub/c.md"} { + body, _, err := v.Read("imported/" + note) + if err != nil { + t.Fatal(err) + } + refs := Analyze("imported/"+note, body).Assets + if len(refs) != 1 || !strings.HasPrefix(string(body), map[string]string{"a.md": "![](./assets/", "b.md": "[图片](./assets/", "sub/c.md": " 0 { - candidate = fmt.Sprintf("%s-%d%s", base, i, path.Ext(name)) + candidate = fmt.Sprintf("%s-%d%s", base, i, ext) } - target := filepath.Join(dir, candidate) - if err := v.checkInside(target); err != nil { - return "", err + rel := path.Join(dir, candidate) + full, err := v.abs(rel) + if err != nil { + return "", false, err } - existing, err := os.ReadFile(target) + info, err := os.Lstat(full) if errors.Is(err, fs.ErrNotExist) { - if err := atomicWrite(target, data); err != nil { - return "", err - } - } else if err != nil { - return "", err - } else if !bytes.Equal(existing, data) { - continue + return rel, false, nil + } + if err != nil { + return "", false, err + } + if !info.Mode().IsRegular() { + return "", false, ErrInvalidPath + } + existing, err := os.ReadFile(full) + if err != nil { + return "", false, err + } + if bytes.Equal(existing, data) { + return rel, true, nil } - return EscapeLinkPath(path.Base(relativeDir)) + "/" + candidate, nil } } diff --git a/internal/vault/vault_test.go b/internal/vault/vault_test.go index 3e37f59..087f93e 100644 --- a/internal/vault/vault_test.go +++ b/internal/vault/vault_test.go @@ -18,6 +18,20 @@ func newVault(t *testing.T) *Vault { return v } +// 显式构造旧版独立附件,覆盖升级后的移动与回收站恢复。 +func legacyAsset(t *testing.T, v *Vault, note, name, content string) string { + t.Helper() + dir := filepath.Join(v.Root(), filepath.FromSlash(AssetsDir(note))) + if err := os.MkdirAll(dir, 0755); err != nil { + t.Fatal(err) + } + file := assetName([]byte(content), name) + if err := os.WriteFile(filepath.Join(dir, file), []byte(content), 0644); err != nil { + t.Fatal(err) + } + return EscapeLinkPath(filepath.Base(dir)) + "/" + file +} + func TestCleanPathRejectsUnsafe(t *testing.T) { for _, p := range []string{"", "/a.md", "../a.md", "a/../b.md", "a//b.md", "./a.md", ".rawmark/x.md", "a/.git/b.md", "a\\b.md", "a\x00.md"} { if _, err := CleanPath(p); !errors.Is(err, ErrInvalidPath) { @@ -48,7 +62,7 @@ func TestSymlinkEscapeRejected(t *testing.T) { func TestAssetSymlinkEscapeRejected(t *testing.T) { v := newVault(t) outside := t.TempDir() - if err := os.Symlink(outside, filepath.Join(v.Root(), "n.assets")); err != nil { + if err := os.Symlink(outside, filepath.Join(v.Root(), "assets")); err != nil { t.Fatal(err) } if _, err := v.SaveAsset("n.md", "x.png", strings.NewReader("image"), 100); !errors.Is(err, ErrInvalidPath) { @@ -70,9 +84,7 @@ func TestMoveRollsBackWhenAssetDestinationIsTooLong(t *testing.T) { if _, err := v.Write("refs.md", []byte(refs), Precondition{}); err != nil { t.Fatal(err) } - if _, err := v.SaveAsset("n.md", "x.png", strings.NewReader("image"), 100); err != nil { - t.Fatal(err) - } + legacyAsset(t, v, "n.md", "x.png", "image") // 笔记名可写入,但派生的 .assets 名超过文件系统的单段长度限制。 destination := strings.Repeat("a", 250) + ".md" if _, err := v.Move("n.md", destination); err == nil { @@ -96,9 +108,7 @@ func TestRestoreRollsBackWhenAssetsAreMissing(t *testing.T) { if _, err := v.Write("n.md", []byte("原文"), Precondition{}); err != nil { t.Fatal(err) } - if _, err := v.SaveAsset("n.md", "x.png", strings.NewReader("image"), 100); err != nil { - t.Fatal(err) - } + legacyAsset(t, v, "n.md", "x.png", "image") item, err := v.Delete("n.md") if err != nil { t.Fatal(err) @@ -118,9 +128,9 @@ func TestRestoreRollsBackWhenAssetsAreMissing(t *testing.T) { } } -func TestAssetLinksEncodeReservedURLCharacters(t *testing.T) { +func TestAssetLinksWithReservedNotePath(t *testing.T) { v := newVault(t) - link, err := v.SaveAsset("n#?%20.md", "x.png", strings.NewReader("image"), 100) + link, err := v.SaveAsset("目录 #?%20/n.md", "x.png", strings.NewReader("image"), 100) if err != nil { t.Fatal(err) } @@ -128,12 +138,12 @@ func TestAssetLinksEncodeReservedURLCharacters(t *testing.T) { if err != nil { t.Fatal(err) } - file, _, err := v.OpenRaw(decoded) + file, _, err := v.OpenRaw(filepath.ToSlash(filepath.Join("目录 #?%20", decoded))) if err != nil { t.Fatalf("资源链接未指向实际文件: %s %v", link, err) } file.Close() - if strings.ContainsAny(link, "#?") || !strings.HasPrefix(link, "n%23%3F%2520.assets/") { + if strings.ContainsAny(link, "#?") || !strings.HasPrefix(link, "./assets/") { t.Fatalf("链接未正确转义: %s", link) } } @@ -170,10 +180,7 @@ func TestMoveCarriesAssetsAndRewritesLinks(t *testing.T) { if _, err := v.Write("dir/笔记 A.md", []byte(src), Precondition{}); err != nil { t.Fatal(err) } - link, err := v.SaveAsset("dir/笔记 A.md", "x.png", strings.NewReader("png"), 1<<20) - if err != nil { - t.Fatal(err) - } + link := legacyAsset(t, v, "dir/笔记 A.md", "x.png", "png") if !strings.HasPrefix(link, "笔记%20A.assets/") { t.Fatalf("资源链接不符: %s", link) } @@ -199,7 +206,7 @@ func TestMoveCarriesAssetsAndRewritesLinks(t *testing.T) { func TestDeleteRestore(t *testing.T) { v := newVault(t) v.Write("n.md", []byte("x"), Precondition{}) - v.SaveAsset("n.md", "a.png", strings.NewReader("img"), 100) + legacyAsset(t, v, "n.md", "a.png", "img") item, err := v.Delete("n.md") if err != nil { t.Fatal(err) @@ -245,7 +252,7 @@ func TestTreeShowsImagesButHidesMeta(t *testing.T) { for _, e := range entries { paths = append(paths, e.Path) } - if strings.Join(paths, ",") != "a.assets,"+link+",a.md,folder" { + if strings.Join(paths, ",") != "a.md,assets,"+strings.TrimPrefix(link, "./")+",folder" { t.Fatalf("目录树不符: %v", paths) } }