fix(export): exclude application data from portable notes

This commit is contained in:
eezd committed 2026-10-25 13:59:54 +08:00
1 parent 9449a1aa65
commit c705d1ab33
5 files changed
+159 -4

No files matched your search

+117
View File
@@ -0,0 +1,117 @@
package api
import (
"archive/zip"
"bytes"
"io"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"testing/fstest"
"rawmark/internal/auth"
"rawmark/internal/events"
"rawmark/internal/search"
"rawmark/internal/store"
"rawmark/internal/vault"
)
func TestPortableNotesExcludeApplicationData(t *testing.T) {
for _, layout := range []string{"default", "same-root", "nested-app", "symlink-data"} {
t.Run(layout, func(t *testing.T) {
root := t.TempDir()
data := root
switch layout {
case "default":
root = filepath.Join(data, "vault")
case "nested-app", "symlink-data":
data = filepath.Join(root, "application")
}
if err := os.MkdirAll(data, 0700); err != nil {
t.Fatal(err)
}
if layout == "symlink-data" {
link := filepath.Join(t.TempDir(), "data")
if err := os.Symlink(data, link); err != nil {
t.Fatal(err)
}
data = link
}
v, err := vault.Open(root)
if err != nil {
t.Fatal(err)
}
st, err := store.Open(filepath.Join(data, "rawmark.db"))
if err != nil {
t.Fatal(err)
}
defer st.Close()
am := auth.New(st)
if err = am.Setup("disposable-export-password"); err != nil {
t.Fatal(err)
}
idx, err := search.Open(filepath.Join(data, "index.db"), v)
if err != nil {
t.Fatal(err)
}
defer idx.Close()
server := New(v, am, st, events.NewHub(), idx)
server.DataDir = data
srv := httptest.NewServer(server.Handler(fstest.MapFS{}))
defer srv.Close()
f := &fixture{t: t, url: srv.URL, root: root, store: st, admin: newClient()}
res := f.do(f.admin, "POST", "/api/v1/auth/login", `{"password":"disposable-export-password"}`, nil)
assertOK(t, res)
res.Body.Close()
f.write("note.md", "# portable note\n![](assets/a.png)")
f.write("assets/a.png", "image")
f.write("personal/fonts/keep.dat", "user resource")
for _, name := range []string{"temp", "backups", "logs", "fonts", "themes", "plugins"} {
dir := filepath.Join(data, name)
if err = os.MkdirAll(dir, 0700); err != nil {
t.Fatal(err)
}
if err = os.WriteFile(filepath.Join(dir, "application.dat"), []byte("private"), 0600); err != nil {
t.Fatal(err)
}
}
// 与真实进程一致,导出暂存文件位于应用数据目录内。
t.Setenv("TMPDIR", filepath.Join(data, "temp"))
res = f.do(f.admin, "POST", "/api/v1/maintenance/notes/export", "", nil)
defer res.Body.Close()
assertOK(t, res)
body, err := io.ReadAll(res.Body)
if err != nil {
t.Fatal(err)
}
z, err := zip.NewReader(bytes.NewReader(body), int64(len(body)))
if err != nil {
t.Fatal(err)
}
want := map[string]string{"note.md": "# portable note\n![](assets/a.png)", "assets/a.png": "image", "personal/fonts/keep.dat": "user resource"}
for _, file := range z.File {
if file.FileInfo().IsDir() {
continue
}
expected, ok := want[file.Name]
if !ok {
t.Fatalf("笔记导出包含应用数据或临时文件:%s", file.Name)
}
r, err := file.Open()
if err != nil {
t.Fatal(err)
}
content, err := io.ReadAll(r)
r.Close()
if err != nil || string(content) != expected {
t.Fatalf("笔记或附件内容改变:%s,%v", file.Name, err)
}
delete(want, file.Name)
}
if len(want) != 0 {
t.Fatalf("遗漏用户文件:%v", want)
}
})
}
}
+7 -1
View File
@@ -209,7 +209,13 @@ func (s *Server) exportBackup(w http.ResponseWriter, r *http.Request) {
}
func (s *Server) exportNotes(w http.ResponseWriter, r *http.Request) {
err := s.maintain("导出笔记数据", func() error {
file, err := s.vault.ExportNotes(r.Context())
dataRoot, err := filepath.EvalSymlinks(s.DataDir)
if err != nil {
return err
}
file, err := s.vault.ExportNotes(r.Context(), func(rel string) bool {
return backup.VaultExcluded(dataRoot, s.vault.Root(), rel)
})
if err != nil {
return err
}
+8 -1
View File
@@ -17,7 +17,8 @@ type MissingAsset struct {
Path string `json:"path"`
}
func (v *Vault) ExportNotes(ctx context.Context) (result *os.File, err error) {
// exclude 由应用层提供,笔记库不持有数据库和资源目录的配置。
func (v *Vault) ExportNotes(ctx context.Context, exclude func(string) bool) (result *os.File, err error) {
v.mu.Lock()
defer v.mu.Unlock()
f, err := os.CreateTemp("", "rawmark-notes-*.zip")
@@ -47,6 +48,12 @@ func (v *Vault) ExportNotes(ctx context.Context) (result *os.File, err error) {
return nil
}
rel = filepath.ToSlash(rel)
if full == f.Name() || (exclude != nil && exclude(rel)) {
if d.IsDir() {
return filepath.SkipDir
}
return nil
}
for _, p := range strings.Split(rel, "/") {
if strings.HasPrefix(p, ".") {
if d.IsDir() {
+26 -1
View File
@@ -20,7 +20,7 @@ func TestExportNotesKeepsMarkdownAndReportsMissingAssets(t *testing.T) {
}
v.Delete("other.md")
v.SaveAsset("note.md", "a.png", strings.NewReader("image"), 50<<20)
file, err := v.ExportNotes(context.Background())
file, err := v.ExportNotes(context.Background(), nil)
if err != nil {
t.Fatal(err)
}
@@ -58,3 +58,28 @@ func TestExportNotesKeepsMarkdownAndReportsMissingAssets(t *testing.T) {
t.Fatalf("导出包不能导回: %v", err)
}
}
func TestExportNotesExcludesItsOwnOutputInsideVault(t *testing.T) {
v := newVault(t)
t.Setenv("TMPDIR", v.Root())
if _, err := v.Write("note.md", []byte("source"), Precondition{}); err != nil {
t.Fatal(err)
}
file, err := v.ExportNotes(context.Background(), nil)
if err != nil {
t.Fatal(err)
}
defer os.Remove(file.Name())
defer file.Close()
info, err := file.Stat()
if err != nil {
t.Fatal(err)
}
z, err := zip.NewReader(file, info.Size())
if err != nil {
t.Fatal(err)
}
if len(z.File) != 1 || z.File[0].Name != "note.md" {
t.Fatal("导出包含自身临时文件")
}
}