diff --git a/internal/api/api.go b/internal/api/api.go index 5ce5099..ad17d0c 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -76,6 +76,7 @@ func (s *Server) routes() []route { {"POST /api/v1/auth/password", sessionOnly, s.changePassword}, {"GET /api/v1/tree", authed, s.tree}, + {"POST /api/v1/export", authed, s.export}, {"GET /api/v1/files/{path...}", authed, s.readFile}, {"PUT /api/v1/files/{path...}", authed, s.writeFile}, {"DELETE /api/v1/files/{path...}", authed, s.deleteFile}, @@ -182,7 +183,7 @@ func (s *Server) requireAuth(next http.Handler, allowToken bool) http.Handler { writeError(w, 401, "API 令牌无效") return } - if scope != "write" && r.Method != http.MethodGet && r.Method != http.MethodHead { + if scope != "write" && r.Method != http.MethodGet && r.Method != http.MethodHead && !(r.Method == http.MethodPost && r.URL.Path == "/api/v1/export") { writeError(w, 403, "只读令牌不能修改数据") return } diff --git a/internal/api/export.go b/internal/api/export.go new file mode 100644 index 0000000..6d85ecf --- /dev/null +++ b/internal/api/export.go @@ -0,0 +1,32 @@ +package api + +import ( + "mime" + "net/http" + "os" + "strings" + "time" +) + +func (s *Server) export(w http.ResponseWriter, r *http.Request) { + var body struct { + Paths []string `json:"paths"` + } + if !readJSON(w, r, &body) { + return + } + file, name, err := s.vault.Export(r.Context(), body.Paths) + if err != nil { + vaultError(w, r, err) + return + } + defer file.Close() + defer os.Remove(file.Name()) + contentType := "application/zip" + if strings.HasSuffix(name, ".md") { + contentType = "text/markdown; charset=utf-8" + } + w.Header().Set("Content-Type", contentType) + w.Header().Set("Content-Disposition", mime.FormatMediaType("attachment", map[string]string{"filename": name})) + http.ServeContent(w, r, name, time.Time{}, file) +} diff --git a/internal/api/export_test.go b/internal/api/export_test.go new file mode 100644 index 0000000..2840861 --- /dev/null +++ b/internal/api/export_test.go @@ -0,0 +1,73 @@ +package api + +import ( + "archive/zip" + "bytes" + "io" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestExportMarkdownAndReferencedAssets(t *testing.T) { + f := newFixture(t) + f.write("资料/文章.md", "# 标题\n![图片](../assets/图.png)\n`![](不存在.png)`\n[其他笔记](未选中.md)") + if err := os.MkdirAll(filepath.Join(f.root, "assets"), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(f.root, "assets/图.png"), []byte("图片内容"), 0644); err != nil { + t.Fatal(err) + } + response := f.do(f.admin, "POST", "/api/v1/export", `{"paths":["资料/文章.md"]}`, nil) + data, _ := io.ReadAll(response.Body) + response.Body.Close() + if response.StatusCode != 200 { + t.Fatalf("导出失败:%d %s", response.StatusCode, data) + } + reader, err := zip.NewReader(bytes.NewReader(data), int64(len(data))) + if err != nil { + t.Fatal(err) + } + files := map[string]string{} + for _, file := range reader.File { + r, err := file.Open() + if err != nil { + t.Fatal(err) + } + content, _ := io.ReadAll(r) + r.Close() + files[file.Name] = string(content) + } + if files["assets/图.png"] != "图片内容" || !strings.Contains(files["资料/文章.md"], "../assets/图.png") || len(files) != 2 { + t.Fatalf("附件或目录不符:%v", files) + } + f.write("单篇.md", "原始 Markdown") + response = f.do(f.admin, "POST", "/api/v1/export", `{"paths":["单篇.md"]}`, nil) + defer response.Body.Close() + data, _ = io.ReadAll(response.Body) + if response.StatusCode != 200 || string(data) != "原始 Markdown" || !strings.HasPrefix(response.Header.Get("Content-Type"), "text/markdown") { + t.Fatalf("单篇导出不符:%d %s", response.StatusCode, data) + } +} + +func TestExportRejectsMissingAssetsAndSymlinks(t *testing.T) { + f := newFixture(t) + f.write("文章.md", "![](missing.png)") + for _, body := range []string{`{"paths":["文章.md"]}`, `{"paths":[".rawmark/app.db"]}`, `{"paths":[]}`} { + response := f.do(f.admin, "POST", "/api/v1/export", body, nil) + response.Body.Close() + if response.StatusCode == 200 { + t.Fatalf("不应成功:%s", body) + } + } + f.write("真实.md", "内容") + if err := os.Symlink(filepath.Join(f.root, "真实.md"), filepath.Join(f.root, "别名.md")); err != nil { + t.Fatal(err) + } + response := f.do(f.admin, "POST", "/api/v1/export", `{"paths":["别名.md"]}`, nil) + response.Body.Close() + if response.StatusCode != 400 { + t.Fatalf("应拒绝符号链接:%d", response.StatusCode) + } +} diff --git a/internal/api/openapi.json b/internal/api/openapi.json index 863b4a5..2615d5c 100644 --- a/internal/api/openapi.json +++ b/internal/api/openapi.json @@ -3,7 +3,7 @@ "info": { "title": "RawMark API", "version": "1", - "description": "RawMark 的 REST API。自动化调用请在「API 令牌」中创建令牌,以 `Authorization: Bearer ` 访问;只读令牌只能使用 GET。分享与令牌的管理接口只接受登录会话。所有错误响应均为 `{\"error\": \"说明\"}`。" + "description": "RawMark 的 REST API。自动化调用请在「API 令牌」中创建令牌,以 `Authorization: Bearer ` 访问;只读令牌可使用 GET 和 POST /export(只读导出)。分享与令牌的管理接口只接受登录会话。所有错误响应均为 `{\"error\": \"说明\"}`。" }, "servers": [ { @@ -2326,6 +2326,61 @@ } } } + }, + "/export": { + "post": { + "summary": "导出原始 Markdown 和本地附件", + "description": "接收非空路径列表;单篇无附件返回 .md,目录、多选或含附件返回 ZIP。ZIP 保留仓库相对路径;不下载外部资源,不跟随其他未选中笔记。缺失附件或非法路径返回错误。此 POST 接口不修改数据,只读令牌可调用。", + "tags": [ + "文件" + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "paths" + ], + "properties": { + "paths": { + "type": "array", + "minItems": 1, + "items": { + "type": "string" + } + } + } + } + } + } + }, + "responses": { + "200": { + "description": "原始 Markdown 或 ZIP 附件", + "content": { + "application/zip": { + "schema": { + "type": "string", + "format": "binary" + } + }, + "text/markdown": { + "schema": { + "type": "string" + } + } + } + }, + "400": { + "description": "路径无效" + }, + "404": { + "description": "文件或附件不存在" + } + } + } } }, "components": { diff --git a/internal/vault/export.go b/internal/vault/export.go new file mode 100644 index 0000000..5f9675c --- /dev/null +++ b/internal/vault/export.go @@ -0,0 +1,197 @@ +package vault + +import ( + "archive/zip" + "context" + "fmt" + "html" + "io" + "net/url" + "os" + "path" + "path/filepath" + "sort" + "strings" +) + +// Export 在临时文件中完成导出,成功后由调用方关闭并删除文件。 +// ZIP 保留仓库相对路径,正文不改写;只导出原始文件,不解析或下载外部资源。 +func (v *Vault) Export(ctx context.Context, paths []string) (result *os.File, name string, err error) { + if len(paths) == 0 { + return nil, "", fmt.Errorf("%w:请选择导出内容", ErrInvalidPath) + } + v.mu.Lock() + defer v.mu.Unlock() + files := map[string]bool{} + directory := false + for _, p := range paths { + info, e := v.Stat(p) + if e != nil { + return nil, "", e + } + if e = v.exportPath(p); e != nil { + return nil, "", e + } + if !info.IsDir() { + files[p] = false + continue + } + directory = true + files[p] = true + entries, e := v.TreeAt(p) + if e != nil { + return nil, "", e + } + for _, entry := range entries { + files[path.Join(p, entry.Path)] = entry.Dir + } + } + // 仅收集已选笔记的附件,不追踪链接到的其他笔记。 + notes := make([]string, 0) + for p, dir := range files { + if !dir && IsNote(p) { + notes = append(notes, p) + } + } + for _, note := range notes { + if e := ctx.Err(); e != nil { + return nil, "", e + } + content, _, e := v.Read(note) + if e != nil { + return nil, "", e + } + var linkError error + transformMarkdownLines(content, importRef, func(line []byte) []byte { + return transformOutsideCode(line, func(text []byte) []byte { + for _, span := range importLinkSpans(text) { + target := string(text[span.start:span.end]) + if span.html { + target = html.UnescapeString(target) + } else { + target = markdownEscape.ReplaceAllString(target, "$1") + } + u, e := url.Parse(target) + if e != nil { + linkError = fmt.Errorf("%w:附件地址无效 %s", ErrInvalidPath, target) + break + } + if u.IsAbs() || u.Host != "" || strings.HasPrefix(target, "/") || u.Path == "" { + continue + } + rel := path.Clean(path.Join(path.Dir(note), u.Path)) + if IsNote(rel) { + continue + } + if e = v.exportPath(rel); e != nil { + linkError = fmt.Errorf("附件 %s:%w", rel, e) + break + } + info, e := v.Stat(rel) + if e != nil { + linkError = fmt.Errorf("附件 %s:%w", rel, e) + break + } + if !info.IsDir() { + files[rel] = false + } + } + return text + }) + }) + if linkError != nil { + return nil, "", linkError + } + } + tmp, e := os.CreateTemp("", "rawmark-export-*") + if e != nil { + return nil, "", e + } + defer func() { + if err != nil { + tmp.Close() + os.Remove(tmp.Name()) + } + }() + if len(paths) == 1 && !directory && len(files) == 1 && IsNote(paths[0]) { + source, _, e := v.OpenRaw(paths[0]) + if e != nil { + return nil, "", e + } + _, e = io.Copy(tmp, source) + source.Close() + if e != nil { + return nil, "", e + } + name = path.Base(paths[0]) + } else { + writer := zip.NewWriter(tmp) + ordered := make([]string, 0, len(files)) + for p := range files { + ordered = append(ordered, p) + } + sort.Strings(ordered) + for _, p := range ordered { + if e := ctx.Err(); e != nil { + return nil, "", e + } + if e := v.exportPath(p); e != nil { + return nil, "", e + } + if files[p] { + if _, e := writer.Create(p + "/"); e != nil { + return nil, "", e + } + continue + } + source, info, e := v.OpenRaw(p) + if e != nil { + return nil, "", e + } + header, e := zip.FileInfoHeader(info) + if e != nil { + source.Close() + return nil, "", e + } + header.Name, header.Method = p, zip.Deflate + dest, e := writer.CreateHeader(header) + if e == nil { + _, e = io.Copy(dest, source) + } + source.Close() + if e != nil { + return nil, "", e + } + } + if e := writer.Close(); e != nil { + return nil, "", e + } + name = "导出.zip" + if len(paths) == 1 { + name = strings.TrimSuffix(path.Base(paths[0]), NoteExt) + ".zip" + } + } + if _, e := tmp.Seek(0, io.SeekStart); e != nil { + return nil, "", e + } + return tmp, name, nil +} + +// 附件可能通过 ../ 指向其他目录,但不能读取隐藏路径或经符号链接绕过范围。 +func (v *Vault) exportPath(rel string) error { + full, err := v.abs(rel) + if err != nil { + return err + } + real, err := filepath.EvalSymlinks(full) + if os.IsNotExist(err) { + return ErrNotFound + } + if err != nil { + return err + } + if real != full { + return ErrInvalidPath + } + return nil +} diff --git a/web/src/app/MoveDialog.vue b/web/src/app/MoveDialog.vue index 4921584..c7280e6 100644 --- a/web/src/app/MoveDialog.vue +++ b/web/src/app/MoveDialog.vue @@ -1,27 +1,27 @@