fix(export): exclude application data from portable notes
This commit is contained in:
1 parent
9449a1aa65
commit
c705d1ab33
5 files changed
+159
-4
No files matched your search
@@ -44,7 +44,7 @@ docker run -d --name rawmark \
|
||||
- 设置:桌面使用居中窗口,手机使用分类与内容两级布局;支持搜索设置并定位、常用排版项单独重置,自动保存状态与失败提示可见。外观与编辑器分别管理,安装入口位于「关于」。
|
||||
- 回收站与存储:回收站支持清空全部;默认按原删除时间保留 30 天,0 表示永不自动删除,服务启动及每小时执行清理,修改期限同样影响已有条目。设置「存储空间」进入即统计数据目录、外置笔记库及内置可选字体(不含默认 Maple),支持刷新和字体明细;不跟随内部符号链接,附件与回收站包含在笔记库总量中。
|
||||
- 快捷键:支持按命令名称、ID 或组合键搜索,添加、逐条移除和单项/全部重置,并拒绝冲突绑定。默认跟随 [Typora](https://support.typora.io/Shortcut-Keys/) 的 Windows/Linux 或 macOS 组合,无对应项和插件默认留空;浏览器保留键可自行重新绑定。Ctrl/Cmd+1~6 设置标题,同级再次触发恢复正文;字号可直接输入,编辑器字号随有效输入即时生效。
|
||||
- 备份与维护:设置「存储空间」提供完整实例、普通 Markdown 笔记库、设置与资源三种迁移入口。完整实例含数据库、登录信息、回收站与资源,恢复会替换当前业务数据并要求重新登录;设置包含偏好、插件数据及字体/主题/插件,保留包外资源,同 ID 资源替换。两者应用前均在数据目录 `backups/` 保存恢复包;不包含运维环境配置、派生索引或临时文件。普通笔记导出保留目录与原文,缺失附件记录于 `.rawmark-export/missing-assets.json`,导回时忽略该报告并导入新文件夹。
|
||||
- 备份与维护:设置「存储空间」提供完整实例、普通 Markdown 笔记库、设置与资源三种迁移入口。完整实例含数据库、登录信息、回收站与资源,恢复会替换当前业务数据并要求重新登录;设置包含偏好、插件数据及字体/主题/插件,保留包外资源,同 ID 资源替换。两者应用前均在数据目录 `backups/` 保存恢复包;不包含运维环境配置、派生索引或临时文件。普通笔记导出保留目录与原文,即使仓库与应用目录重合或嵌套也不包含应用数据库、资源与暂存文件;缺失附件记录于 `.rawmark-export/missing-assets.json`,导回时忽略该报告并导入新文件夹。
|
||||
- 维护边界:备份恢复只接受本程序的版本化 ZIP,顺序分块上传,每块 8 MiB,压缩上限 2 GiB、展开上限 8 GiB/100000 项;完整恢复前需停止外部程序直接改写仓库。维护期间暂时暂停 API 操作,实例恢复在关闭数据库和后台任务后执行,中断时回退操作前备份。优化/重建仅作用于派生索引;临时清理不删除用户文件、活动上传或恢复备份。重置偏好保留插件存储、账户、资源和本机标签页。日志保存于 `logs/`,三份、每份至多 10 MiB,可导出日志及版本运行摘要,不补录历史日志。
|
||||
|
||||
- 文件目录操作:右键「文件目录」标题或目录空白处,可搜索、打开标签索引、新建笔记、新建目录、导入文档及管理图片;也可点击标题打开菜单。文件夹右键操作以所选文件夹为目标。
|
||||
|
||||
@@ -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")
|
||||
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": "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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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("导出包含自身临时文件")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user